Files
honeyDueKMP/iosApp/iosApp/RootView.swift
Trey t 9c574c4343 Harden iOS app with audit fixes, UI consistency, and sheet race condition fixes
Applies verified fixes from deep audit (concurrency, performance, security,
accessibility), standardizes CRUD form buttons to Add/Save pattern, removes
.drawingGroup() that broke search bar TextFields, and converts vulnerable
.sheet(isPresented:) + if-let patterns to safe presentation to prevent
blank white modals.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 09:59:56 -06:00

225 lines
8.3 KiB
Swift

import SwiftUI
import ComposeApp
/// Shared authentication state manager
@MainActor
class AuthenticationManager: ObservableObject {
static let shared = AuthenticationManager()
@Published var isAuthenticated: Bool = false
@Published var isVerified: Bool = false
@Published var isCheckingAuth: Bool = true
private init() {
checkAuthenticationStatus()
}
func checkAuthenticationStatus() {
if UITestRuntime.isEnabled {
isAuthenticated = DataManager.shared.isAuthenticated()
isVerified = isAuthenticated
isCheckingAuth = false
return
}
isCheckingAuth = true
// Check if token exists via DataManager (single source of truth)
guard DataManager.shared.isAuthenticated() else {
isAuthenticated = false
isVerified = false
isCheckingAuth = false
return
}
isAuthenticated = true
// Fetch current user to validate token and check verification status
Task { @MainActor in
do {
// Lookups are already initialized by iOSApp.init() at startup
// and refreshed by scenePhase .active handler no need to call again here
let result = try await APILayer.shared.getCurrentUser(forceRefresh: true)
if let success = result as? ApiResultSuccess<User> {
self.isVerified = success.data?.verified ?? false
// Register device for push notifications for authenticated users
PushNotificationManager.shared.registerDeviceAfterLogin()
// Verify subscription entitlements with backend for verified users
if self.isVerified {
await StoreKitManager.shared.verifyEntitlementsOnLaunch()
}
} else if result is ApiResultError {
// Token is invalid, clear all data via DataManager
DataManager.shared.clear()
self.isAuthenticated = false
self.isVerified = false
}
} catch {
#if DEBUG
print("Failed to check auth status: \(error)")
#endif
// Distinguish network errors from auth errors
let nsError = error as NSError
if nsError.domain == NSURLErrorDomain {
// Network error keep authenticated state, user may be offline
#if DEBUG
print("Network error during auth check, keeping auth state")
#endif
} else {
// Auth error token is invalid
DataManager.shared.clear()
self.isAuthenticated = false
self.isVerified = false
}
}
self.isCheckingAuth = false
}
}
func login(verified: Bool) {
isAuthenticated = true
isVerified = verified
guard !UITestRuntime.isEnabled else { return }
// Register device for push notifications now that user is authenticated
PushNotificationManager.shared.registerDeviceAfterLogin()
}
func markVerified() {
isVerified = true
guard !UITestRuntime.isEnabled else { return }
// Lookups are already initialized at app start or during login/register
// Just verify subscription entitlements after user becomes verified
Task {
await StoreKitManager.shared.verifyEntitlementsOnLaunch()
print("✅ Subscription entitlements verified after email verification")
}
}
func logout() {
// Call APILayer logout which clears DataManager
Task {
_ = try? await APILayer.shared.logout()
}
// Clear widget data (tasks and auth token)
WidgetDataManager.shared.clearCache()
WidgetDataManager.shared.clearAuthToken()
// Clear authenticated image cache
AuthenticatedImage.clearCache()
// Update authentication state
isAuthenticated = false
isVerified = false
// Note: We don't reset onboarding state on logout
// so returning users go to login screen, not onboarding
#if DEBUG
print("AuthenticationManager: Logged out - all state reset")
#endif
}
/// Reset onboarding state (for testing or re-onboarding)
func resetOnboarding() {
OnboardingState.shared.reset()
}
}
/// Root view that handles authentication flow: loading -> onboarding -> login -> verify email -> main app
struct RootView: View {
@EnvironmentObject private var themeManager: ThemeManager
@StateObject private var authManager = AuthenticationManager.shared
@StateObject private var onboardingState = OnboardingState.shared
@State private var refreshID = UUID()
@Binding var deepLinkResetToken: String?
var body: some View {
ZStack(alignment: .topLeading) {
Group {
if authManager.isCheckingAuth {
// Show loading while checking auth status
loadingView
.accessibilityIdentifier(AccessibilityIdentifiers.Common.loadingIndicator)
} else if !onboardingState.hasCompletedOnboarding {
// Show onboarding for first-time users (includes auth + verification steps)
// This takes precedence because we need to finish the onboarding flow
ZStack(alignment: .topLeading) {
OnboardingCoordinator(onComplete: {
// Onboarding complete - mark verified and refresh the view
authManager.markVerified()
// Force refresh all data so tasks created during onboarding appear
Task {
_ = try? await APILayer.shared.getMyResidences(forceRefresh: true)
_ = try? await APILayer.shared.getTasks(forceRefresh: true)
}
refreshID = UUID()
})
Color.clear
.frame(width: 1, height: 1)
.accessibilityIdentifier("ui.root.onboarding")
}
} else if !authManager.isAuthenticated {
// Show login screen for returning users
ZStack(alignment: .topLeading) {
LoginView(resetToken: $deepLinkResetToken)
Color.clear
.frame(width: 1, height: 1)
.accessibilityIdentifier("ui.root.login")
}
} else if !authManager.isVerified {
// Show email verification screen (for returning users who haven't verified)
VerifyEmailView(
onVerifySuccess: {
authManager.markVerified()
},
onLogout: {
authManager.logout()
}
)
} else {
// Show main app
ZStack(alignment: .topLeading) {
MainTabView(refreshID: refreshID)
.onChange(of: themeManager.currentTheme) { _, _ in
refreshID = UUID()
}
Color.clear
.frame(width: 1, height: 1)
.accessibilityIdentifier("ui.root.mainTabs")
}
}
}
Color.clear
.frame(width: 1, height: 1)
.accessibilityIdentifier("ui.app.ready")
}
}
private var loadingView: some View {
ZStack {
Color.appBackgroundPrimary
.ignoresSafeArea()
VStack(spacing: 16) {
ProgressView()
.scaleEffect(1.5)
.tint(Color.appPrimary)
Text("Loading...")
.font(.subheadline)
.foregroundColor(Color.appTextSecondary)
}
}
}
}