This commit is contained in:
Daniel Arantes Loverde
2026-07-07 15:11:31 -03:00
parent 8b9ebdcb44
commit e51c99973f
293 changed files with 457 additions and 4919 deletions

View File

@@ -0,0 +1,431 @@
import Foundation
import SwiftUI
struct ContentView: View {
@State var root: RootFlow = DefaultTokenStore().jwt == nil ? .auth : .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?
@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()
}
.onDisappear {
detachCartResetObserver()
detachAppResumeObserver()
detachSessionExpiredObserver()
}
}
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
}
// Always refresh profile when authenticated.
// This keeps profile/address/cart scope consistent after relogin
// and avoids stale local state during checkout payload generation.
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
}
@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 {
AddressesView(
message: appState.address.onboardingMessage,
appState: $appState,
selectionMode: true
)
.onAppear {
appState.address.onboardingMessage = nil
}
}
}
}