- 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>
184 lines
6.3 KiB
Swift
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
|
|
}
|
|
}
|