migration
This commit is contained in:
531
PediFoods/ContentView.swift
Normal file
531
PediFoods/ContentView.swift
Normal file
@@ -0,0 +1,531 @@
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
enum DeepLinkRouteEffect: Equatable {
|
||||
case navigateToOrder(OrderRouteContext, tab: MainTab)
|
||||
case none
|
||||
}
|
||||
|
||||
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, tokenStore: tokenStore, appState: $appState, enterAuth: enterAuthFlow)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
/// Pure mapping from a deep-link destination to what `route(to:)` should
|
||||
/// do - split out so it's testable without a live `ContentView`
|
||||
/// instance (this reads no `@State`, `route(to:)` is what applies the
|
||||
/// result to `appState`/`selectedTab`).
|
||||
func routeEffect(for destination: DeepLinkDestination) -> DeepLinkRouteEffect {
|
||||
switch destination {
|
||||
case .orderTracking(let orderId, let shortId):
|
||||
return .navigateToOrder(
|
||||
OrderRouteContext(
|
||||
orderId: orderId,
|
||||
shortId: shortId,
|
||||
paymentMethod: nil,
|
||||
total: nil,
|
||||
intent: .auto
|
||||
),
|
||||
tab: .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)")
|
||||
return .none
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func route(to destination: DeepLinkDestination) {
|
||||
guard root == .main else { return }
|
||||
switch routeEffect(for: destination) {
|
||||
case .navigateToOrder(let context, let tab):
|
||||
appState.pendingOrderDeepLink = context
|
||||
selectedTab = tab
|
||||
case .none:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
@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
|
||||
enterAuthFlow()
|
||||
}
|
||||
|
||||
/// The only correct way to transition `root` to `.auth`: pairs the
|
||||
/// switch with the token bump `LoginView` needs to run its entry
|
||||
/// reveal animation. A bare `root = .auth` leaves the login screen's
|
||||
/// hero/text/button stuck hidden - see
|
||||
/// decisions/2026-08-06-login-entry-animation-bug.md.
|
||||
@MainActor
|
||||
private func enterAuthFlow() {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user