login flow

This commit is contained in:
Daniel Arantes Loverde
2026-02-12 09:44:40 -03:00
parent 3176b67914
commit 38fc71718b
12 changed files with 710 additions and 111 deletions

View File

@@ -5,6 +5,10 @@ struct ContentView: View {
@State var selectedTab: MainTab = .home
private let tokenStore: TokenStore = DefaultTokenStore()
@State var appState = AppState()
@State var isBootstrappingSession = false
#if os(iOS)
@State private var sessionExpiredObserver: NSObjectProtocol?
#endif
#if os(Android)
@State var snackbarCenter = SnackbarCenter.shared
#else
@@ -18,7 +22,11 @@ struct ContentView: View {
case .auth:
AuthFlowView(root: $root, selectedTab: $selectedTab, tokenStore: tokenStore, appState: $appState)
case .main:
MainTabView(selectedTab: $selectedTab, root: $root, tokenStore: tokenStore, appState: $appState)
if isBootstrappingSession {
sessionBootstrapLoadingView
} else {
MainTabView(selectedTab: $selectedTab, root: $root, tokenStore: tokenStore, appState: $appState)
}
}
}
@@ -32,48 +40,187 @@ struct ContentView: View {
FiltersModalView()
}
}
.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(id: root) {
await bootstrapSessionStateIfNeeded()
}
.onAppear {
#if os(iOS)
attachSessionExpiredObserverIfNeeded()
#endif
}
.onDisappear {
#if os(iOS)
detachSessionExpiredObserver()
#endif
}
}
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 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()
}
// Only refresh profile when there is no local address cache.
// This avoids forcing Profile flow on startup and still recovers
// existing addresses already registered in backend.
if hasConfiguredAddress() == false {
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.
}
}
isBootstrappingSession = false
}
@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 ?? ""
SessionStateStore.setActiveUserKey(
SessionStateStore.makeUserKey(profileId: customer.id, email: customer.email)
)
let addresses = customer.addressBook ?? []
guard addresses.isEmpty == false else {
return
}
let preferredAddress = addresses.first(where: { $0.id == appState.address.selectedId }) ?? addresses.first
if let preferredAddress {
applyAddress(preferredAddress)
SessionStateStore.saveAddress(appState.address)
}
}
@MainActor
private func applyAddress(_ address: CustomerAddress) {
appState.address.selectedId = address.id
let cleanLabel = (address.label ?? "")
.trimmingCharacters(in: .whitespacesAndNewlines)
if cleanLabel.isEmpty == false {
appState.address.display = cleanLabel
}
if let lat = address.latLong?.first, let lng = address.latLong?.dropFirst().first {
appState.address.latitude = lat
appState.address.longitude = lng
}
}
@MainActor
private func forceLogoutToStart() {
tokenStore.clear()
SessionStateStore.clearActiveUser()
isBootstrappingSession = false
appState = AppState()
selectedTab = .home
root = .auth
}
private func hasConfiguredAddress() -> Bool {
if appState.address.selectedId != nil {
return true
}
if appState.address.latitude != nil, appState.address.longitude != 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
}
}
#if os(iOS)
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
}
#endif
}
struct AddressPickerModalView: View {
@Binding var appState: AppState
@Binding var selectedTab: MainTab
@Environment(\.dismiss) var dismiss
var body: some View {
NavigationStack {
VStack(spacing: 12) {
if let message = appState.address.onboardingMessage {
Text(message)
.font(AppTypography.body)
.multilineTextAlignment(.center)
.foregroundStyle(AppColors.textPrimary)
.padding(.horizontal, 16)
.padding(.vertical, 12)
.frame(maxWidth: .infinity)
.background(AppColors.brandSoft)
.clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous))
.padding(.horizontal, 20)
}
Text("Selecionar endereco")
.font(AppTypography.heading2)
Text("Fluxo de endereco sera implementado na etapa de checkout/perfil.")
.font(AppTypography.body)
.multilineTextAlignment(.center)
.foregroundStyle(AppColors.textMuted)
Button("Ir para Perfil > Endereços") {
selectedTab = .profile
dismiss()
}
.font(AppTypography.heading3)
.foregroundStyle(AppColors.primary)
.padding(.top, 8)
AddressesView(
message: appState.address.onboardingMessage,
appState: $appState,
selectionMode: true
)
.onAppear {
appState.address.onboardingMessage = nil
}
.padding(24)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(AppColors.backgroundLight)
}
}
}