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>
390 lines
18 KiB
Swift
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()
|
|
}
|