Files
honeyDueKMP/iosApp/iosApp/Login/LoginView.swift
Trey t a2b81a244b Implement custom 5-color design system across entire iOS app
Apply consistent branding colors (BlueGreen, Cerulean, BrightAmber, PrimaryScarlet,
cream backgrounds) to all screens, components, buttons, icons, and text throughout
the app. Update all Form/List views with proper list row backgrounds to ensure
visual consistency with card-based layouts.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-21 07:58:01 -06:00

308 lines
14 KiB
Swift

import SwiftUI
struct LoginView: View {
@StateObject private var viewModel = LoginViewModel()
@FocusState private var focusedField: Field?
@State private var showingRegister = false
@State private var showVerification = false
@State private var showPasswordReset = false
@State private var isPasswordVisible = false
@Binding var resetToken: String?
var onLoginSuccess: (() -> Void)?
init(resetToken: Binding<String?> = .constant(nil), onLoginSuccess: (() -> Void)? = nil) {
_resetToken = resetToken
self.onLoginSuccess = onLoginSuccess
}
enum Field {
case username, password
}
// Computed properties to help type checker
private var isFormValid: Bool {
!viewModel.username.isEmpty && !viewModel.password.isEmpty
}
private var buttonBackgroundColor: Color {
if viewModel.isLoading || !isFormValid {
return Color.appTextSecondary
}
return .clear
}
private var shouldShowShadow: Bool {
isFormValid && !viewModel.isLoading
}
var body: some View {
NavigationView {
ZStack {
// Background gradient
Color.appBackgroundPrimary
.ignoresSafeArea()
ScrollView {
VStack(spacing: AppSpacing.xl) {
Spacer()
.frame(height: AppSpacing.xxxl)
// Hero Section
VStack(spacing: AppSpacing.lg) {
// App Icon with gradient
ZStack {
Circle()
.fill(LinearGradient(colors: [Color.appPrimary, Color.appPrimary.opacity(0.8)], startPoint: .topLeading, endPoint: .bottomTrailing))
.frame(width: 100, height: 100)
.shadow(color: Color.appPrimary.opacity(0.3), radius: 20, y: 10)
Image(systemName: "house.fill")
.font(.system(size: 50, weight: .semibold))
.foregroundStyle(.white)
}
VStack(spacing: AppSpacing.xs) {
Text("Welcome Back")
.font(.title2.weight(.bold))
.foregroundColor(Color.appTextPrimary)
Text("Sign in to manage your properties")
.font(.body)
.foregroundColor(Color.appTextSecondary)
}
}
// Login Card
VStack(spacing: AppSpacing.lg) {
// Username Field
VStack(alignment: .leading, spacing: AppSpacing.xs) {
Text("Email or Username")
.font(.subheadline.weight(.medium))
.foregroundColor(Color.appTextSecondary)
HStack(spacing: AppSpacing.sm) {
Image(systemName: "envelope.fill")
.foregroundColor(Color.appTextSecondary)
.frame(width: 20)
TextField("Enter your email", text: $viewModel.username)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.keyboardType(.emailAddress)
.focused($focusedField, equals: .username)
.submitLabel(.next)
.onSubmit {
focusedField = .password
}
.onChange(of: viewModel.username) { _, _ in
viewModel.clearError()
}
.accessibilityIdentifier(AccessibilityIdentifiers.Authentication.usernameField)
}
.padding(AppSpacing.md)
.background(Color.appBackgroundSecondary)
.cornerRadius(AppRadius.md)
.overlay(
RoundedRectangle(cornerRadius: AppRadius.md)
.stroke(focusedField == .username ? Color.appPrimary : Color.appTextSecondary.opacity(0.3), lineWidth: 1.5)
)
.shadow(color: focusedField == .username ? Color.appPrimary.opacity(0.1) : .clear, radius: 8)
.animation(.easeInOut(duration: 0.2), value: focusedField)
}
// Password Field
VStack(alignment: .leading, spacing: AppSpacing.xs) {
Text("Password")
.font(.subheadline.weight(.medium))
.foregroundColor(Color.appTextSecondary)
HStack(spacing: AppSpacing.sm) {
Image(systemName: "lock.fill")
.foregroundColor(Color.appTextSecondary)
.frame(width: 20)
Group {
if isPasswordVisible {
TextField("Enter your password", text: $viewModel.password)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.focused($focusedField, equals: .password)
.submitLabel(.go)
.onSubmit {
viewModel.login()
}
.accessibilityIdentifier(AccessibilityIdentifiers.Authentication.passwordField)
} else {
SecureField("Enter your password", text: $viewModel.password)
.focused($focusedField, equals: .password)
.submitLabel(.go)
.onSubmit {
viewModel.login()
}
.accessibilityIdentifier(AccessibilityIdentifiers.Authentication.passwordField)
}
}
Button(action: {
isPasswordVisible.toggle()
}) {
Image(systemName: isPasswordVisible ? "eye.slash.fill" : "eye.fill")
.foregroundColor(Color.appTextSecondary)
.frame(width: 20)
}
.accessibilityIdentifier(AccessibilityIdentifiers.Authentication.passwordVisibilityToggle)
}
.padding(AppSpacing.md)
.background(Color.appBackgroundSecondary)
.cornerRadius(AppRadius.md)
.overlay(
RoundedRectangle(cornerRadius: AppRadius.md)
.stroke(focusedField == .password ? Color.appPrimary : Color.appTextSecondary.opacity(0.3), lineWidth: 1.5)
)
.shadow(color: focusedField == .password ? Color.appPrimary.opacity(0.1) : .clear, radius: 8)
.animation(.easeInOut(duration: 0.2), value: focusedField)
.onChange(of: viewModel.password) { _, _ in
viewModel.clearError()
}
}
// Forgot Password
HStack {
Spacer()
Button("Forgot Password?") {
showPasswordReset = true
}
.font(.subheadline.weight(.medium))
.foregroundColor(Color.appPrimary)
.accessibilityIdentifier(AccessibilityIdentifiers.Authentication.forgotPasswordButton)
}
// Error Message
if let errorMessage = viewModel.errorMessage {
HStack(spacing: AppSpacing.sm) {
Image(systemName: "exclamationmark.circle.fill")
.foregroundColor(Color.appError)
Text(errorMessage)
.font(.callout)
.foregroundColor(Color.appError)
Spacer()
}
.padding(AppSpacing.md)
.background(Color.appError.opacity(0.1))
.cornerRadius(AppRadius.md)
}
// Login Button
Button(action: viewModel.login) {
loginButtonContent
}
.disabled(!isFormValid || viewModel.isLoading)
.accessibilityIdentifier(AccessibilityIdentifiers.Authentication.loginButton)
// Sign Up Link
HStack(spacing: AppSpacing.xs) {
Text("Don't have an account?")
.font(.body)
.foregroundColor(Color.appTextSecondary)
Button("Sign Up") {
showingRegister = true
}
.font(.body)
.fontWeight(.semibold)
.foregroundColor(Color.appPrimary)
.accessibilityIdentifier(AccessibilityIdentifiers.Authentication.signUpButton)
}
}
.padding(AppSpacing.xl)
.background(Color.appBackgroundSecondary)
.cornerRadius(AppRadius.xxl)
.shadow(color: .black.opacity(0.08), radius: 20, y: 10)
.padding(.horizontal, AppSpacing.lg)
Spacer()
}
}
}
.navigationBarHidden(true)
.onAppear {
// Set up callback for login success
viewModel.onLoginSuccess = { [self] isVerified in
if isVerified {
// User is verified, call the success callback
self.onLoginSuccess?()
} else {
// User needs verification
self.showVerification = true
}
}
}
.fullScreenCover(isPresented: $showVerification) {
VerifyEmailView(
onVerifySuccess: {
viewModel.isVerified = true
showVerification = false
// User is now verified, call the success callback
onLoginSuccess?()
},
onLogout: {
viewModel.logout()
showVerification = false
}
)
}
.sheet(isPresented: $showingRegister) {
RegisterView()
}
.sheet(isPresented: $showPasswordReset) {
PasswordResetFlow(resetToken: resetToken)
}
.onChange(of: resetToken) { _, token in
if token != nil {
showPasswordReset = true
}
}
.handleErrors(
error: viewModel.errorMessage,
onRetry: { viewModel.login() }
)
}
}
// MARK: - Subviews
private var loginButtonContent: some View {
HStack(spacing: AppSpacing.sm) {
if viewModel.isLoading {
ProgressView()
.progressViewStyle(CircularProgressViewStyle(tint: .white))
}
Text(viewModel.isLoading ? "Signing In..." : "Sign In")
.font(.headline)
.fontWeight(.semibold)
}
.frame(maxWidth: .infinity)
.frame(height: 56)
.foregroundColor(Color.appTextOnPrimary)
.background(loginButtonBackground)
.cornerRadius(AppRadius.md)
.shadow(
color: shouldShowShadow ? Color.appPrimary.opacity(0.3) : .clear,
radius: 10,
y: 5
)
}
private var loginButtonBackground: AnyShapeStyle {
if viewModel.isLoading || !isFormValid {
AnyShapeStyle(Color.appTextSecondary)
} else {
AnyShapeStyle(LinearGradient(colors: [Color.appPrimary, Color.appPrimary.opacity(0.8)], startPoint: .topLeading, endPoint: .bottomTrailing))
}
}
}
// MARK: - Preview
#Preview {
LoginView()
}