Files
honeyDueKMP/iosApp/iosApp/Login/LoginView.swift
Trey t 5e3596db77 Complete re-validation remediation: KMP architecture, iOS platform, XCUITest rewrite
Phases 1-6 of fixes.md — closes all 13 issues from codex_issues_2.md re-validation:

KMP Architecture:
- Fix subscription purchase/restore response contract (VerificationResponse aligned)
- Add feature benefits auth token + APILayer init flow
- Remove ResidenceFormScreen direct API bypass (use APILayer)
- Wire paywall purchase/restore to real SubscriptionApi calls

iOS Platform:
- Add iOS Keychain token storage via Swift KeychainHelper
- Implement Google Sign-In via ASWebAuthenticationSession (GoogleSignInManager)
- DocumentViewModelWrapper observes DataManager for auto-updates
- Add missing accessibility identifiers (document, task columns, Google Sign-In)

XCUITest Rewrite:
- Rewrite test infrastructure: zero sleep() calls, accessibility ID lookups
- Create AuthCriticalPathTests and NavigationCriticalPathTests
- Delete 14 legacy brittle test files (Suite0-10, templates)
- Fix CaseraTests module import (@testable import Casera)

All platforms build clean. TEST BUILD SUCCEEDED.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 18:50:13 -06:00

390 lines
18 KiB
Swift

import SwiftUI
import AuthenticationServices
struct LoginView: View {
@StateObject private var viewModel = LoginViewModel()
@StateObject private var appleSignInViewModel = AppleSignInViewModel()
@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
@State private var activeResetToken: String?
@StateObject private var googleSignInManager = GoogleSignInManager.shared
@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
}
// Form validation
private var isFormValid: Bool {
!viewModel.username.isEmpty && !viewModel.password.isEmpty
}
var body: some View {
NavigationView {
ZStack {
// Warm organic background
WarmGradientBackground()
ScrollView(showsIndicators: false) {
VStack(spacing: OrganicSpacing.spacious) {
Spacer()
.frame(height: OrganicSpacing.airy)
// Hero Section
VStack(spacing: OrganicSpacing.comfortable) {
// App Icon with organic glow
ZStack {
Circle()
.fill(
RadialGradient(
colors: [
Color.appPrimary.opacity(0.15),
Color.appPrimary.opacity(0.05),
Color.clear
],
center: .center,
startRadius: 0,
endRadius: 60
)
)
.frame(width: 120, height: 120)
Image("icon")
.resizable()
.scaledToFit()
.frame(width: 80, height: 80)
}
VStack(spacing: 8) {
Text(L10n.Auth.welcomeBack)
.font(.system(size: 26, weight: .bold, design: .rounded))
.foregroundColor(Color.appTextPrimary)
Text(L10n.Auth.signInSubtitle)
.font(.system(size: 15, weight: .medium))
.foregroundColor(Color.appTextSecondary)
}
}
// Login Card
VStack(spacing: 20) {
// Username Field
VStack(alignment: .leading, spacing: 8) {
FieldLabel(text: L10n.Auth.loginUsernameLabel)
IconTextField(
icon: "envelope.fill",
placeholder: L10n.Auth.enterEmail,
text: $viewModel.username,
keyboardType: .emailAddress,
textContentType: .username,
onSubmit: { focusedField = .password }
)
.onChange(of: viewModel.username) { _, _ in
viewModel.clearError()
}
.accessibilityIdentifier(AccessibilityIdentifiers.Authentication.usernameField)
}
// Password Field
VStack(alignment: .leading, spacing: 8) {
FieldLabel(text: L10n.Auth.loginPasswordLabel)
SecureIconTextField(
icon: "lock.fill",
placeholder: L10n.Auth.enterPassword,
text: $viewModel.password,
isVisible: $isPasswordVisible,
textContentType: .password,
onSubmit: { viewModel.login() },
accessibilityId: AccessibilityIdentifiers.Authentication.passwordField
)
.onChange(of: viewModel.password) { _, _ in
viewModel.clearError()
}
}
// Forgot Password
HStack {
Spacer()
Button(L10n.Auth.forgotPassword) {
showPasswordReset = true
}
.font(.system(size: 14, weight: .semibold, design: .rounded))
.foregroundColor(Color.appPrimary)
.accessibilityIdentifier(AccessibilityIdentifiers.Authentication.forgotPasswordButton)
}
// Error Message
if let errorMessage = viewModel.errorMessage {
HStack(spacing: 10) {
Image(systemName: "exclamationmark.circle.fill")
.foregroundColor(Color.appError)
Text(errorMessage)
.font(.system(size: 14, weight: .medium))
.foregroundColor(Color.appError)
Spacer()
}
.padding(16)
.background(Color.appError.opacity(0.1))
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
}
// Login Button
OrganicPrimaryButton(
title: viewModel.isLoading ? L10n.Auth.signingIn : L10n.Auth.loginButton,
isLoading: viewModel.isLoading,
isDisabled: !isFormValid,
action: viewModel.login
)
.accessibilityIdentifier(AccessibilityIdentifiers.Authentication.loginButton)
// Divider
HStack(spacing: 12) {
OrganicDivider()
Text(L10n.Auth.orDivider)
.font(.system(size: 13, weight: .medium))
.foregroundColor(Color.appTextSecondary)
OrganicDivider()
}
.padding(.vertical, 8)
// Sign in with Apple Button
SignInWithAppleButton(
onRequest: { request in
request.requestedScopes = [.fullName, .email]
},
onCompletion: { _ in }
)
.frame(height: 54)
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
.signInWithAppleButtonStyle(.black)
.disabled(appleSignInViewModel.isLoading)
.opacity(appleSignInViewModel.isLoading ? 0.6 : 1.0)
.overlay {
// Custom tap handler to use our view model
Color.clear
.contentShape(Rectangle())
.onTapGesture {
appleSignInViewModel.signInWithApple()
}
}
.accessibilityIdentifier(AccessibilityIdentifiers.Authentication.appleSignInButton)
// Apple Sign In loading indicator
if appleSignInViewModel.isLoading {
HStack(spacing: 8) {
ProgressView()
.progressViewStyle(CircularProgressViewStyle())
.tint(Color.appPrimary)
Text(L10n.Auth.signingInWithApple)
.font(.system(size: 14, weight: .medium))
.foregroundColor(Color.appTextSecondary)
}
.padding(.top, 8)
}
// Google Sign-In Button
Button(action: {
googleSignInManager.signIn()
}) {
HStack(spacing: 10) {
Image(systemName: "globe")
.font(.system(size: 18, weight: .medium))
.foregroundColor(Color.appTextPrimary)
Text("Sign in with Google")
.font(.system(size: 17, weight: .medium))
.foregroundColor(Color.appTextPrimary)
}
.frame(maxWidth: .infinity)
.frame(height: 54)
.background(Color.appBackgroundSecondary)
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: 16, style: .continuous)
.stroke(Color.appTextSecondary.opacity(0.3), lineWidth: 1)
)
}
.accessibilityIdentifier(AccessibilityIdentifiers.Authentication.googleSignInButton)
// Apple Sign In Error
if let appleError = appleSignInViewModel.errorMessage {
HStack(spacing: 10) {
Image(systemName: "exclamationmark.circle.fill")
.foregroundColor(Color.appError)
Text(appleError)
.font(.system(size: 14, weight: .medium))
.foregroundColor(Color.appError)
Spacer()
}
.padding(16)
.background(Color.appError.opacity(0.1))
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
}
// Sign Up Link
HStack(spacing: 6) {
Text(L10n.Auth.dontHaveAccount)
.font(.system(size: 15, weight: .medium))
.foregroundColor(Color.appTextSecondary)
Button(L10n.Auth.signUp) {
showingRegister = true
}
.font(.system(size: 15, weight: .bold, design: .rounded))
.foregroundColor(Color.appPrimary)
.accessibilityIdentifier(AccessibilityIdentifiers.Authentication.signUpButton)
}
.padding(.top, 8)
}
.padding(OrganicSpacing.cozy)
.background(LoginCardBackground())
.clipShape(RoundedRectangle(cornerRadius: 32, style: .continuous))
.naturalShadow(.pronounced)
.padding(.horizontal, 16)
Spacer()
}
}
}
.navigationBarHidden(true)
.onAppear {
// Set up callback for login success
viewModel.onLoginSuccess = { [self] isVerified in
// Update the shared authentication manager
AuthenticationManager.shared.login(verified: isVerified)
if isVerified {
// User is verified, call the success callback
self.onLoginSuccess?()
} else {
// User needs verification - RootView will handle showing VerifyEmailView
// since AuthenticationManager.isVerified is now false
}
}
// Set up callback for Apple Sign In success
appleSignInViewModel.onSignInSuccess = { [self] isVerified in
// Update the shared authentication manager
AuthenticationManager.shared.login(verified: isVerified)
if isVerified {
// User is verified, call the success callback
self.onLoginSuccess?()
} else {
// User needs verification - RootView will handle showing VerifyEmailView
// since AuthenticationManager.isVerified is now false
}
}
// Set up callback for Google Sign In success
googleSignInManager.onSignInSuccess = { [self] isVerified in
AuthenticationManager.shared.login(verified: isVerified)
if isVerified {
self.onLoginSuccess?()
}
}
}
.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: activeResetToken, onLoginSuccess: { isVerified in
// Update the shared authentication manager
AuthenticationManager.shared.login(verified: isVerified)
if isVerified {
// User is verified, call the success callback
self.onLoginSuccess?()
}
// If not verified, RootView will handle showing VerifyEmailView
})
}
.onChange(of: resetToken) { _, token in
if let token {
activeResetToken = token
showPasswordReset = true
resetToken = nil
}
}
.onChange(of: showPasswordReset) { _, isPresented in
if !isPresented {
activeResetToken = nil
}
}
.alert("Google Sign-In Error", isPresented: .init(
get: { googleSignInManager.errorMessage != nil },
set: { if !$0 { googleSignInManager.errorMessage = nil } }
)) {
Button("OK", role: .cancel) { }
} message: {
Text(googleSignInManager.errorMessage ?? "An error occurred.")
}
}
}
}
// MARK: - Login Card Background
private struct LoginCardBackground: View {
@Environment(\.colorScheme) var colorScheme
var body: some View {
ZStack {
Color.appBackgroundSecondary
// Organic blob accent
GeometryReader { geo in
OrganicBlobShape(variation: 2)
.fill(
RadialGradient(
colors: [
Color.appPrimary.opacity(colorScheme == .dark ? 0.08 : 0.05),
Color.appPrimary.opacity(0.01)
],
center: .center,
startRadius: 0,
endRadius: geo.size.width * 0.5
)
)
.frame(width: geo.size.width * 0.6, height: geo.size.height * 0.5)
.offset(x: -geo.size.width * 0.1, y: geo.size.height * 0.3)
.blur(radius: 20)
}
// Grain texture for natural feel
GrainTexture(opacity: 0.015)
}
}
}
// MARK: - Preview
#Preview {
LoginView()
}