login flow
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user