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>
308 lines
14 KiB
Swift
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()
|
|
}
|