import Foundation import SwiftUI struct ContentView: View { @State var root: RootFlow = .main @State var selectedTab: MainTab = .home private let tokenStore: TokenStore = DefaultTokenStore() @State var appState = AppState() @State var isBootstrappingSession = false @State var hasPerformedInitialLaunchBootstrap = false @State var showLaunchSplash = true @State var shouldPulseLaunchSplash = true @State var shouldPrepareAuthEntryAnimation = DefaultTokenStore().jwt == nil @State var authEntryAnimationToken = 0 @State private var sessionExpiredObserver: NSObjectProtocol? @State var cartResetObserver: Any? @State var appResumeObserver: Any? @State var pushDeepLinkObserver: Any? @StateObject var snackbarCenter = SnackbarCenter.shared var body: some View { ZStack(alignment: .top) { Group { switch root { case .auth: AuthFlowView( root: $root, selectedTab: $selectedTab, tokenStore: tokenStore, appState: $appState, shouldPrepareLoginEntry: shouldPrepareAuthEntryAnimation, authEntryAnimationToken: authEntryAnimationToken ) case .main: if isBootstrappingSession { sessionBootstrapLoadingView } else { MainTabView(selectedTab: $selectedTab, root: $root, tokenStore: tokenStore, appState: $appState) } } } SnackbarOverlay(center: snackbarCenter) if showLaunchSplash { LaunchSplashView(shouldPulse: shouldPulseLaunchSplash) .transition(.opacity) } } .sheet(item: $appState.activeModal) { modal in switch modal { case .addressPicker: AddressPickerModalView(appState: $appState, selectedTab: $selectedTab) case .filters: FiltersModalView(appState: $appState) } } .onChange(of: appState.address.display) { _, _ in dismissAddressPickerIfAddressExists() } .onChange(of: appState.address.selectedId) { _, _ in dismissAddressPickerIfAddressExists() } .onChange(of: appState.address.latitude) { _, _ in dismissAddressPickerIfAddressExists() } .onChange(of: appState.address.longitude) { _, _ in dismissAddressPickerIfAddressExists() } .task { await performInitialLaunchBootstrap() } .onChange(of: root) { _, newValue in if newValue == .main { Task { await bootstrapSessionStateIfNeeded() } } } .onChange(of: appState.profile.id) { _, _ in Task { @MainActor in await refreshFeatureFlags(forceRefresh: true) } } .onChange(of: appState.cart.storeId) { _, _ in Task { @MainActor in await refreshFeatureFlags(forceRefresh: true) } } .onAppear { attachCartResetObserverIfNeeded() attachAppResumeObserverIfNeeded() attachSessionExpiredObserverIfNeeded() attachPushDeepLinkObserverIfNeeded() } .onDisappear { detachCartResetObserver() detachAppResumeObserver() detachSessionExpiredObserver() detachPushDeepLinkObserver() } } private var sessionBootstrapLoadingView: some View { VStack(spacing: 12) { ProgressView() Text("Carregando sua sessão...") .font(AppTypography.body) .foregroundStyle(AppColors.textMuted) } .frame(maxWidth: .infinity, maxHeight: .infinity) .background(AppColors.backgroundLight) } @MainActor private func performInitialLaunchBootstrap() async { guard hasPerformedInitialLaunchBootstrap == false else { return } hasPerformedInitialLaunchBootstrap = true let start = Date() await bootstrapSessionStateIfNeeded() // Keep the in-app splash visible long enough to avoid abrupt transition // between native launch screen and app content. let elapsed = Date().timeIntervalSince(start) let minimumSplashDuration: TimeInterval = 1.0 if elapsed < minimumSplashDuration { let remaining = minimumSplashDuration - elapsed let nanoseconds = UInt64(remaining * 1_000_000_000) try? await Task.sleep(nanoseconds: nanoseconds) } if root == .auth { shouldPrepareAuthEntryAnimation = true shouldPulseLaunchSplash = false try? await Task.sleep(nanoseconds: 180_000_000) withAnimation(.easeInOut(duration: 0.34)) { showLaunchSplash = false } try? await Task.sleep(nanoseconds: 360_000_000) authEntryAnimationToken += 1 scheduleDisableAuthEntryPreparation() return } shouldPulseLaunchSplash = false withAnimation(.easeOut(duration: 0.28)) { showLaunchSplash = false } } @MainActor private func bootstrapSessionStateIfNeeded() async { guard root == .main else { return } guard isBootstrappingSession == false else { return } isBootstrappingSession = true if let jwt = tokenStore.jwt { appState.session.jwt = jwt appState.session.isAuthenticated = true } if let cachedAddress = SessionStateStore.loadAddress() { appState.address = cachedAddress dismissAddressPickerIfAddressExists() } if let cachedCart = SessionStateStore.loadCart() { appState.cart = cachedCart } // Anonymous session: the account address cache above doesn't apply — // restore the "ENTREGAR EM" label from the state/city picked via the // public locator (GuestLocationStore persists this in Keychain across // launches on its own; this just resyncs the display label with it). if appState.session.isAuthenticated == false, let guestState = GuestLocationStore.shared.selectedState, let guestCity = GuestLocationStore.shared.selectedCity { appState.address.display = "\(guestCity), \(guestState)" dismissAddressPickerIfAddressExists() } // Always refresh profile when authenticated. // This keeps profile/address/cart scope consistent after relogin // and avoids stale local state during checkout payload generation. // Anonymous browsing has no session to refresh, so skip the call // entirely rather than let it 401 and force-logout a guest. if appState.session.isAuthenticated { do { let response = try await ApiService().profile() if response.error == false, let customer = response.result { hydrateAppState(with: customer) } } catch let error as ApiServiceError { if case .sessionExpired = error { forceLogoutToStart() } } catch { // Keep local state when backend refresh fails transiently } } await refreshFeatureFlags(forceRefresh: false) isBootstrappingSession = false } private func attachCartResetObserverIfNeeded() { guard cartResetObserver == nil else { return } cartResetObserver = NotificationCenter.default.addObserver( forName: .cartDidReset, object: nil, queue: nil ) { _ in Task { @MainActor in appState.cart = CartState() } } } private func detachCartResetObserver() { guard let cartResetObserver else { return } NotificationCenter.default.removeObserver(cartResetObserver) self.cartResetObserver = nil } private func attachAppResumeObserverIfNeeded() { guard appResumeObserver == nil else { return } appResumeObserver = NotificationCenter.default.addObserver( forName: .appDidResume, object: nil, queue: nil ) { _ in Task { @MainActor in guard root == .main else { return } await refreshFeatureFlags(forceRefresh: true) } } } private func detachAppResumeObserver() { guard let appResumeObserver else { return } NotificationCenter.default.removeObserver(appResumeObserver) self.appResumeObserver = nil } /// §6 of the push notifications guide, generalized: any tapped push that /// resolves to a `DeepLinkDestination` (reported by /// `PushNotificationCoordinator`) lands here. Adding a future promo/coupon /// screen means adding one case to `route(to:)` below — this observer, /// the notification name, and the AppState plumbing for it stay put. private func attachPushDeepLinkObserverIfNeeded() { guard pushDeepLinkObserver == nil else { return } pushDeepLinkObserver = NotificationCenter.default.addObserver( forName: .pushDeepLinkReceived, object: nil, queue: nil ) { notification in guard let destination = notification.userInfo?["destination"] as? DeepLinkDestination else { return } Task { @MainActor in route(to: destination) } } } private func detachPushDeepLinkObserver() { guard let pushDeepLinkObserver else { return } NotificationCenter.default.removeObserver(pushDeepLinkObserver) self.pushDeepLinkObserver = nil } @MainActor private func route(to destination: DeepLinkDestination) { guard root == .main else { return } switch destination { case .orderTracking(let orderId, let shortId): appState.pendingOrderDeepLink = OrderRouteContext( orderId: orderId, shortId: shortId, paymentMethod: nil, total: nil, intent: .auto ) selectedTab = .profile case .screen(let name, let params): // Promo/coupon screens (participating stores/products, §6's // `targetScreen` convention) land here once they exist — see // decisions/2026-08-04-push-deeplink-routing-contract.md. logger.debug("Unhandled deep-link screen: \(name, privacy: .public) params=\(params, privacy: .public)") } } @MainActor private func hydrateAppState(with customer: CustomerProfile) { appState.profile.id = customer.id appState.profile.name = customer.name appState.profile.email = customer.email appState.profile.phone = customer.phoneNumber ?? "" appState.profile.profilePicture = customer.profilePicture ?? "" appState.favorites.storeIds = Set(customer.favorites ?? []) SessionStateStore.setActiveUserKey( SessionStateStore.makeUserKey(profileId: customer.id, email: customer.email) ) if let cachedCart = SessionStateStore.loadCart() { appState.cart = cachedCart } else { appState.cart = CartState() } let addresses = customer.addressBook ?? [] guard addresses.isEmpty == false else { appState.address = AddressState() SessionStateStore.clearAddress() return } let preferredAddress = resolvePreferredAddress(from: addresses, current: appState.address) if let preferredAddress { applyAddress(preferredAddress) SessionStateStore.saveAddress(appState.address) } } private func resolvePreferredAddress(from addresses: [CustomerAddress], current: AddressState) -> CustomerAddress? { guard addresses.isEmpty == false else { return nil } if let selectedId = current.selectedId?.trimmingCharacters(in: .whitespacesAndNewlines), selectedId.isEmpty == false, let byId = addresses.first(where: { ($0.id ?? "").trimmingCharacters(in: .whitespacesAndNewlines) == selectedId }) { return byId } let normalizedDisplay = current.display .trimmingCharacters(in: .whitespacesAndNewlines) .folding(options: [.diacriticInsensitive, .caseInsensitive], locale: .current) .lowercased() if normalizedDisplay.isEmpty == false && normalizedDisplay != "defina seu endereco", let byLabel = addresses.first(where: { (($0.label ?? "") .trimmingCharacters(in: .whitespacesAndNewlines) .folding(options: [.diacriticInsensitive, .caseInsensitive], locale: .current) .lowercased()) == normalizedDisplay }) { return byLabel } if let lat = current.latitude, let lng = current.longitude, let byCoordinate = addresses.first(where: { address in guard let addrLat = address.latLong?.first, let addrLng = address.latLong?.dropFirst().first else { return false } return abs(addrLat - lat) < 0.00001 && abs(addrLng - lng) < 0.00001 }) { return byCoordinate } return addresses.first } @MainActor private func applyAddress(_ address: CustomerAddress) { appState.address.selectedId = address.id let cleanLabel = (address.label ?? "") .trimmingCharacters(in: .whitespacesAndNewlines) appState.address.display = cleanLabel.isEmpty ? "Defina seu endereco" : cleanLabel if let lat = address.latLong?.first, let lng = address.latLong?.dropFirst().first { appState.address.latitude = lat appState.address.longitude = lng } else { appState.address.latitude = nil appState.address.longitude = nil } } @MainActor private func forceLogoutToStart() { tokenStore.clear() SessionStateStore.clearActiveUser() SessionStateStore.clearTrackedOrders() SessionStateStore.clearPendingCartOrder() AppContentCache.shared.invalidate() AppImageCache.shared.invalidateAll() isBootstrappingSession = false appState = AppState() selectedTab = .home shouldPrepareAuthEntryAnimation = true root = .auth authEntryAnimationToken += 1 scheduleDisableAuthEntryPreparation() } @MainActor private func refreshFeatureFlags(forceRefresh: Bool) async { guard root == .main else { return } let subjectType: String let subjectId: String if let profileId = appState.profile.id?.trimmingCharacters(in: .whitespacesAndNewlines), profileId.isEmpty == false { subjectType = "customer" subjectId = profileId } else { subjectType = "anonymous" subjectId = "anonymous-device" } var attrs: [String: String] = [:] let addressLabel = appState.address.display.trimmingCharacters(in: .whitespacesAndNewlines) if addressLabel.isEmpty == false, addressLabel.lowercased() != "defina seu endereco" { attrs["addressLabel"] = addressLabel } let context = FeatureControlEvaluationContext( subjectType: subjectType, subjectId: subjectId, storeId: appState.cart.storeId, attributes: attrs ) let snapshot = await FeatureControlService.shared.evaluate( context: context, jwt: appState.session.jwt, forceRefresh: forceRefresh ) appState.featureFlags = snapshot await FeatureControlService.shared.sendExposureEvents( snapshot: snapshot, context: context, jwt: appState.session.jwt ) } private func scheduleDisableAuthEntryPreparation() { Task { @MainActor in try? await Task.sleep(nanoseconds: 900_000_000) shouldPrepareAuthEntryAnimation = false } } private func hasConfiguredAddress() -> Bool { if appState.address.selectedId != nil { return true } let normalized = appState.address.display .trimmingCharacters(in: .whitespacesAndNewlines) .lowercased() return normalized.isEmpty == false && normalized != "defina seu endereco" } @MainActor private func dismissAddressPickerIfAddressExists() { guard appState.activeModal == .addressPicker else { return } if hasConfiguredAddress() { appState.activeModal = nil } } private func attachSessionExpiredObserverIfNeeded() { guard sessionExpiredObserver == nil else { return } sessionExpiredObserver = NotificationCenter.default.addObserver( forName: .sessionExpired, object: nil, queue: .main ) { _ in Task { @MainActor in forceLogoutToStart() } } } private func detachSessionExpiredObserver() { guard let observer = sessionExpiredObserver else { return } NotificationCenter.default.removeObserver(observer) sessionExpiredObserver = nil } } struct AddressPickerModalView: View { @Binding var appState: AppState @Binding var selectedTab: MainTab var body: some View { NavigationStack { if appState.session.isAuthenticated { AddressesView( message: appState.address.onboardingMessage, appState: $appState, selectionMode: true ) .onAppear { appState.address.onboardingMessage = nil } } else { PublicLocationPickerView(appState: $appState) } } } }