diff --git a/IngrediCheck/Components/PersistentBottomSheet.swift b/IngrediCheck/Components/PersistentBottomSheet.swift index 5a50915..8579346 100644 --- a/IngrediCheck/Components/PersistentBottomSheet.swift +++ b/IngrediCheck/Components/PersistentBottomSheet.swift @@ -585,18 +585,25 @@ struct PersistentBottomSheet: View { case .whosThisFor: WhosThisFor { AnalyticsService.shared.trackOnboarding("Onboarding Flow Selected", properties: ["flow_type": "individual"]) - // Guest login already happened on .heyThere screen, just proceed + await authController.signIn() + guard authController.session != nil else { + Log.error("PersistentBottomSheet", "Sign-in failed, cannot create Bite Buddy family") + return + } do { try await familyStore.createBiteBuddyFamily() coordinator.showCanvas(.dietaryPreferencesAndRestrictions(isFamilyFlow: false)) coordinator.navigateInBottomSheet(.dietaryPreferencesSheet(isFamilyFlow: false)) } catch { Log.error("PersistentBottomSheet", "Failed to create Bite Buddy family: \(error)") - // Don't navigate forward on error - user stays on current screen } } addFamilyPressed: { AnalyticsService.shared.trackOnboarding("Onboarding Flow Selected", properties: ["flow_type": "family"]) - // Guest login already happened on .heyThere screen, just proceed + await authController.signIn() + guard authController.session != nil else { + Log.error("PersistentBottomSheet", "Sign-in failed, cannot proceed to family flow") + return + } coordinator.showCanvas(.letsMeetYourIngrediFam) } diff --git a/IngrediCheck/Store/AuthController.swift b/IngrediCheck/Store/AuthController.swift index d0d381b..35fe956 100644 --- a/IngrediCheck/Store/AuthController.swift +++ b/IngrediCheck/Store/AuthController.swift @@ -132,9 +132,8 @@ private enum AuthFlowMode { private let keychain = KeychainSwift() @MainActor private var appleSignInCoordinator: AppleSignInCoordinator? - - private static let anonUserNameKey = "anonEmail" - private static let anonPasswordKey = "anonPassword" + @MainActor private var signInTask: Task? + private static let deviceIdKey = "deviceId" private static var hasRegisteredDevice = false @@ -293,27 +292,36 @@ private enum AuthFlowMode { await MainActor.run { OnboardingPersistence.shared.reset() - clearAnonymousCredentials() Self.hasRegisteredDevice = false } } + @MainActor func signIn() async { - - Log.debug("AuthController", "signIn()") - - guard await signInState != .signedIn else { - Log.debug("AuthController", "Already Signed In, so not Signing in again") + guard signInState != .signedIn else { + Log.debug("AuthController", "signIn(): Already signed in") return } - if let anonymousEmail = keychain.get(AuthController.anonUserNameKey), - let anonymousPassword = keychain.get(AuthController.anonPasswordKey), - await signInWithLegacyGuest(email: anonymousEmail, password: anonymousPassword) { + // Deduplicate: if sign-in is already in flight, join it + if let existing = signInTask { + Log.debug("AuthController", "signIn(): Joining existing sign-in task") + await existing.value return } - await signInWithNewAnonymousAccount() + Log.debug("AuthController", "signIn(): Starting new sign-in") + + // Unstructured Task — does NOT inherit parent's cancellation, + // so SwiftUI .task(id:) cancellation won't kill the network request. + // Inherits @MainActor (no Sendable issues with self). + let task = Task { + await signInWithNewAnonymousAccount() + } + + signInTask = task + await task.value + signInTask = nil } @MainActor @@ -342,7 +350,6 @@ private enum AuthFlowMode { let session = try await finalizeAuth(with: credentials, mode: .link) self.session = session - clearAnonymousCredentials() isUpgradingAccount = false } catch { isUpgradingAccount = false @@ -356,24 +363,14 @@ private enum AuthFlowMode { let webService = WebService() try? await webService.deleteUserAccount() await self.signOut() - clearAnonymousCredentials() } - private func signInWithLegacyGuest(email: String, password: String) async -> Bool { - do { - _ = try await supabaseClient.auth.signIn(email: email, password: password) - return true - } catch { - Log.error("AuthController", "Anonymous signin failed for stored credentials: \(error)") - keychain.delete(AuthController.anonUserNameKey) - keychain.delete(AuthController.anonPasswordKey) - return false - } - } - private func signInWithNewAnonymousAccount() async { do { - _ = try await supabaseClient.auth.signInAnonymously() + let session = try await supabaseClient.auth.signInAnonymously() + await MainActor.run { + self.handleSessionChange(event: .signedIn, session: session) + } } catch { Log.error("AuthController", "signInAnonymously failed: \(error)") } @@ -532,11 +529,6 @@ private enum AuthFlowMode { return rootViewController } - private func clearAnonymousCredentials() { - keychain.delete(AuthController.anonUserNameKey) - keychain.delete(AuthController.anonPasswordKey) - } - public func signInWithGoogle(completion: ((Result) -> Void)? = nil) { Task { do { diff --git a/IngrediCheck/Views/HeyThereScreen.swift b/IngrediCheck/Views/HeyThereScreen.swift index c53bc52..9ae8e89 100644 --- a/IngrediCheck/Views/HeyThereScreen.swift +++ b/IngrediCheck/Views/HeyThereScreen.swift @@ -83,19 +83,19 @@ struct HeyThereScreen: View { } } .task(id: coordinator.currentBottomSheetRoute) { - // Trigger anonymous sign-in only when explicitly asking "Who's this for?" - // This prevents "Get Started" or other pre-onboarding states from creating sessions. if coordinator.currentBottomSheetRoute == .whosThisFor { if authController.session == nil { print("[OnboardingMeta] Auto-triggering guest login on .whosThisFor screen") await authController.signIn() - // Sync initial state to Supabase immediately after session is created - print("[OnboardingMeta] Syncing initial state after guest login") - await OnboardingPersistence.shared.sync(from: coordinator) - - // Pre-download tutorial video in background - Task { await TutorialVideoManager.shared.downloadIfNeeded() } + // signIn() creates an internal unstructured Task that survives + // cancellation, so the sign-in completes regardless. But post-work + // (sync, video download) should only run if we're still on this route. + if !Task.isCancelled { + print("[OnboardingMeta] Syncing initial state after guest login") + await OnboardingPersistence.shared.sync(from: coordinator) + Task { await TutorialVideoManager.shared.downloadIfNeeded() } + } } } }