Files
honeyDueKMP/iosApp/iosApp/Login/LoginViewModel.swift
Trey t c5f2bee83f Harden iOS app: fix concurrency, animations, formatters, privacy, and logging
- Eliminate NumberFormatters shared singleton data race; use local formatters
- Add reduceMotion checks to empty-state animations in 3 list views
- Wrap 68+ print() statements in #if DEBUG across push notification code
- Remove redundant .receive(on: DispatchQueue.main) in SubscriptionCache
- Remove redundant initializeLookups() call from iOSApp.init()
- Clean up StoreKitManager Task capture in listenForTransactions()
- Add memory warning observer to AuthenticatedImage cache
- Cache parseContent result in UpgradePromptView init
- Add DiskSpace and FileTimestamp API declarations to Privacy Manifest
- Add FIXME for analytics debug/production API key separation
- Use static formatter in PropertyHeaderCard

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 23:15:42 -06:00

184 lines
6.3 KiB
Swift

import Foundation
import ComposeApp
import Combine
/// ViewModel for user login.
/// Observes DataManagerObservable for authentication state.
/// Kicks off API calls that update DataManager, letting views react to cache updates.
@MainActor
class LoginViewModel: ObservableObject {
// MARK: - Published Properties (from DataManager observation)
@Published var currentUser: User?
@Published var isAuthenticated: Bool = false
// MARK: - Local State
@Published var username: String = ""
@Published var password: String = ""
@Published var isLoading: Bool = false
@Published var errorMessage: String?
@Published var isVerified: Bool = false
// Callback for successful login
var onLoginSuccess: ((Bool) -> Void)?
// MARK: - Private Properties
private var cancellables = Set<AnyCancellable>()
// MARK: - Initialization
init() {
// Observe DataManagerObservable for authentication state
DataManagerObservable.shared.$currentUser
.receive(on: DispatchQueue.main)
.sink { [weak self] user in
self?.currentUser = user
}
.store(in: &cancellables)
DataManagerObservable.shared.$isAuthenticated
.receive(on: DispatchQueue.main)
.sink { [weak self] isAuth in
self?.isAuthenticated = isAuth
}
.store(in: &cancellables)
}
// MARK: - Public Methods
func login() {
guard !username.isEmpty else {
errorMessage = "Username is required"
return
}
guard !password.isEmpty else {
errorMessage = "Password is required"
return
}
isLoading = true
errorMessage = nil
if UITestRuntime.shouldMockAuth {
// Deterministic UI-test auth path scoped behind launch args.
if username == "testuser" && password == "TestPass123!" {
isVerified = true
isLoading = false
onLoginSuccess?(true)
} else {
isLoading = false
errorMessage = "Invalid username or password"
}
return
}
Task {
do {
let result = try await APILayer.shared.login(
request: LoginRequest(username: username, password: password)
)
if let success = result as? ApiResultSuccess<AuthResponse>,
let response = success.data {
// APILayer.login already stores token in DataManager
// currentUser will be updated via DataManagerObservable observation
self.isVerified = response.user.verified
self.isLoading = false
#if DEBUG
print("Login successful!")
print("User: \(response.user.username ?? "unknown"), Verified: \(self.isVerified)")
#endif
// Share token and API URL with widget extension
WidgetDataManager.shared.saveAuthToken(response.token)
WidgetDataManager.shared.saveAPIBaseURL(ApiClient.shared.getBaseUrl())
// Track successful login
AnalyticsManager.shared.track(.userSignedIn(method: "email"))
// Lookups are already initialized by APILayer.login() internally
// (see APILayer.kt line 1205) no need to call again here
// Call login success callback
self.onLoginSuccess?(self.isVerified)
} else if let error = ApiResultBridge.error(from: result) {
self.isLoading = false
self.handleLoginError(error)
} else {
self.isLoading = false
self.errorMessage = "Failed to sign in"
}
} catch {
self.isLoading = false
self.errorMessage = ErrorMessageParser.parse(error.localizedDescription)
}
}
}
private func handleLoginError(_ error: ApiResultError) {
// Check for specific error codes and provide user-friendly messages
if let code = error.code?.intValue {
switch code {
case 401:
self.errorMessage = "Invalid username or password"
case 403:
self.errorMessage = "Access denied. Please check your credentials."
case 404:
self.errorMessage = "Service not found. Please try again later."
case 409:
// Conflict - let backend message explain the issue
self.errorMessage = ErrorMessageParser.parse(error.message)
case 400:
// Bad request - validation errors from backend
self.errorMessage = ErrorMessageParser.parse(error.message)
case 500...599:
self.errorMessage = "Server error. Please try again later."
default:
self.errorMessage = ErrorMessageParser.parse(error.message)
}
} else {
self.errorMessage = ErrorMessageParser.parse(error.message)
}
#if DEBUG
print("API Error: \(error.message)")
#endif
}
func logout() {
Task {
// APILayer.logout clears DataManager
do {
_ = try await APILayer.shared.logout()
} catch {
#if DEBUG
print("Logout error: \(error)")
#endif
}
SubscriptionCacheWrapper.shared.clear()
PushNotificationManager.shared.clearRegistrationCache()
// Clear widget task data
WidgetDataManager.shared.clearCache()
WidgetDataManager.shared.clearAuthToken()
// Clear authenticated image cache
AuthenticatedImage.clearCache()
// Reset local state
self.isVerified = false
self.currentUser = nil
self.username = ""
self.password = ""
self.errorMessage = nil
#if DEBUG
print("Logged out - all state reset")
#endif
}
}
func clearError() {
errorMessage = nil
}
}