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

@@ -187,7 +187,7 @@ struct OtpView: View {
SnackbarCenter.shared.show(title: "Login realizado com sucesso.", style: .success, icon: "checkmark.seal.fill", duration: 2.5)
appState.session.isAuthenticated = true
appState.session.jwt = response.result?.token
appState.profile.email = email
hydrateUserState(from: response.result?.customer)
routeAfterLogin()
}
} catch {
@@ -221,6 +221,44 @@ struct OtpView: View {
root = .main
}
private func hydrateUserState(from customer: CustomerProfile?) {
if let customer {
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)
)
if let preferred = customer.addressBook?.first {
appState.address.selectedId = preferred.id
let label = (preferred.label ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
appState.address.display = label.isEmpty ? "Defina seu endereco" : label
if let lat = preferred.latLong?.first, let lng = preferred.latLong?.dropFirst().first {
appState.address.latitude = lat
appState.address.longitude = lng
}
SessionStateStore.saveAddress(appState.address)
} else if let cached = SessionStateStore.loadAddress() {
appState.address = cached
} else {
appState.address = AddressState()
}
return
}
appState.profile.email = email
SessionStateStore.setActiveUserKey(
SessionStateStore.makeUserKey(profileId: nil, email: email)
)
if let cached = SessionStateStore.loadAddress() {
appState.address = cached
} else {
appState.address = AddressState()
}
}
private func hasConfiguredAddress() -> Bool {
if appState.address.selectedId != nil {
return true

View File

@@ -1,6 +1,7 @@
import Foundation
import SwiftUI
#if os(iOS)
import LCEssentials
import UIKit
#endif
@@ -9,7 +10,7 @@ struct HomeView: View {
@State var searchText = ""
@State var selectedCategory = "all"
@State var categories: [CategoryModel] = [
.init(id: "all", title: "Todas", systemIcon: "line.3.horizontal.decrease.circle")
.init(id: "all", title: "Todas", systemIcon: "line.3.horizontal.decrease.circle", emojiIcon: nil)
]
@State var scrollOffset: CGFloat = 0
@State var collapseBaseOffset: CGFloat = 0
@@ -120,7 +121,8 @@ struct HomeView: View {
Task {
await bootstrapStoresFlow(
forceLocationRefresh: true,
category: selectedCategory == "all" ? nil : selectedCategory
category: selectedCategory == "all" ? nil : selectedCategory,
refreshCategories: true
)
}
}
@@ -166,13 +168,15 @@ struct HomeView: View {
.foregroundStyle(AppColors.brandDark)
)
VStack(alignment: .leading, spacing: 4) {
VStack(alignment: .center, spacing: 4) {
Text("DELIVERY LOCATION")
.font(AppTypography.overline)
.tracking(AppTypography.captionLetterSpacing)
.foregroundStyle(AppColors.brandSoft)
.multilineTextAlignment(.center)
Button {
appState.address.onboardingMessage = nil
appState.activeModal = .addressPicker
} label: {
HStack(spacing: 6) {
@@ -186,8 +190,7 @@ struct HomeView: View {
}
.buttonStyle(.plain)
}
Spacer()
.frame(maxWidth: .infinity, alignment: .center)
Circle()
.fill(Color.white.opacity(0.18))
@@ -199,13 +202,21 @@ struct HomeView: View {
}
.opacity(topRowOpacity)
.offset(y: collapseProgress * -12)
Text("O que vai querer \npedir hoje?")
.font(AppTypography.heading2)
.foregroundStyle(AppColors.textInverse)
.opacity(titleOpacity)
.offset(y: collapseProgress * -20)
if UIDevice().modelName.lowercased().contains("se") || UIDevice().modelName.lowercased().contains("16e") {
Text("O que vai querer \npedir hoje?")
.font(AppTypography.heading2)
.foregroundStyle(AppColors.textInverse)
.opacity(titleOpacity)
.offset(y: collapseProgress * -20)
} else {
Text("O que vai querer pedir hoje?\n ")
.font(AppTypography.heading2)
.foregroundStyle(AppColors.textInverse)
.opacity(titleOpacity)
.offset(y: collapseProgress * -20)
}
SearchBar(placeholder: "Search menu, restaurant or craving", text: $searchText) {
appState.activeModal = .filters
}
@@ -240,6 +251,7 @@ struct HomeView: View {
CategoryChip(
title: category.title,
systemIcon: category.systemIcon,
emojiIcon: category.emojiIcon,
isActive: category.id == selectedCategory
)
.onTapGesture {
@@ -293,10 +305,27 @@ struct HomeView: View {
category: store.category ?? "Loja",
promoText: nil,
isFavorite: appState.favorites.storeIds.contains(store.id),
iconName: "storefront"
iconName: "storefront",
imageURL: resolveStoreImageURL(logo: store.logo, cover: store.cover)
)
}
private func resolveStoreImageURL(logo: String?, cover: String?) -> String? {
let preferred = [logo, cover]
.compactMap { $0?.trimmingCharacters(in: .whitespacesAndNewlines) }
.first { $0.isEmpty == false }
guard let raw = preferred else { return nil }
if raw.lowercased().hasPrefix("http://") || raw.lowercased().hasPrefix("https://") {
return raw
}
let base = ApiConfig.baseURL.absoluteString.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
let path = raw.hasPrefix("/") ? raw : "/\(raw)"
return "\(base)\(path)"
}
private func formatDistance(_ distance: Double?) -> String {
guard let distance else { return "Distância indisponível" }
if distance >= 1 {
@@ -314,7 +343,10 @@ struct HomeView: View {
isLoadingStores = true
storesError = nil
guard let coordinate = await resolveCoordinates(forceRefresh: forceLocationRefresh) else {
let coordinate = await resolveCoordinates(forceRefresh: forceLocationRefresh)
let hasAddress = hasConfiguredAddress()
if coordinate == nil && hasAddress == false {
isLoadingStores = false
stores = []
storesError = "Defina um endereço para visualizar os estabelecimentos mais próximos."
@@ -324,7 +356,11 @@ struct HomeView: View {
}
do {
let response = try await ApiService().listStores(lat: coordinate.0, lng: coordinate.1, category: category)
let response = try await ApiService().listStores(
lat: coordinate?.0,
lng: coordinate?.1,
category: category
)
isLoadingStores = false
if response.error {
stores = []
@@ -333,8 +369,8 @@ struct HomeView: View {
}
let results = response.result ?? []
stores = results
if refreshCategories {
categories = buildCategories(from: results)
if refreshCategories || (category == nil && categories.count <= 1) {
await loadHomeCategories(withFallbackStores: results)
if categories.contains(where: { $0.id == selectedCategory }) == false {
selectedCategory = "all"
}
@@ -349,7 +385,7 @@ struct HomeView: View {
private func buildCategories(from stores: [StoreSummary]) -> [CategoryModel] {
var unique: [CategoryModel] = [
.init(id: "all", title: "Todas", systemIcon: "line.3.horizontal.decrease.circle")
.init(id: "all", title: "Todas", systemIcon: "line.3.horizontal.decrease.circle", emojiIcon: nil)
]
var seen = Set<String>()
@@ -359,11 +395,59 @@ struct HomeView: View {
let dedupe = raw.lowercased()
if seen.contains(dedupe) { continue }
seen.insert(dedupe)
unique.append(.init(id: raw, title: raw, systemIcon: categoryIcon(for: raw)))
unique.append(.init(id: raw, title: raw, systemIcon: categoryIcon(for: raw), emojiIcon: nil))
}
return unique
}
@MainActor
private func loadHomeCategories(withFallbackStores stores: [StoreSummary]) async {
do {
let response = try await ApiService().listPublicCategories()
if response.error == false, let remote = response.result, remote.isEmpty == false {
categories = mapPublicCategories(remote)
return
}
} catch {
// Fallback handled below.
}
categories = buildCategories(from: stores)
}
private func mapPublicCategories(_ remote: [PublicCategory]) -> [CategoryModel] {
var mapped: [CategoryModel] = []
var seen = Set<String>()
for item in remote {
let id = item.id.trimmingCharacters(in: .whitespacesAndNewlines)
let title = item.name.trimmingCharacters(in: .whitespacesAndNewlines)
if id.isEmpty || title.isEmpty { continue }
if seen.contains(id.lowercased()) { continue }
seen.insert(id.lowercased())
mapped.append(
.init(
id: id,
title: title,
systemIcon: id.lowercased() == "all" ? "line.3.horizontal.decrease.circle" : nil,
emojiIcon: item.icon
)
)
}
if mapped.contains(where: { $0.id.lowercased() == "all" }) == false {
mapped.insert(.init(id: "all", title: "Todas", systemIcon: "line.3.horizontal.decrease.circle", emojiIcon: nil), at: 0)
} else {
mapped.sort { lhs, rhs in
if lhs.id.lowercased() == "all" { return true }
if rhs.id.lowercased() == "all" { return false }
return lhs.title < rhs.title
}
}
return mapped
}
private func categoryIcon(for category: String) -> String {
let value = category.folding(options: [.diacriticInsensitive, .caseInsensitive], locale: .current).lowercased()
if value.contains("pizza") { return "takeoutbag.and.cup.and.straw" }
@@ -440,18 +524,25 @@ struct HomeView: View {
struct CategoryModel: Identifiable {
let id: String
let title: String
let systemIcon: String
let systemIcon: String?
let emojiIcon: String?
}
struct CategoryChip: View {
let title: String
let systemIcon: String
let systemIcon: String?
let emojiIcon: String?
let isActive: Bool
var body: some View {
HStack(spacing: 8) {
Image(systemName: systemIcon)
.font(.caption)
if let emojiIcon, emojiIcon.isEmpty == false {
Text(emojiIcon)
.font(.body)
} else if let systemIcon, systemIcon.isEmpty == false {
Image(systemName: systemIcon)
.font(.caption)
}
Text(title)
.font(AppTypography.heading3)
}

View File

@@ -32,8 +32,8 @@ struct ProfileView: View {
SecondaryButton(title: "Sair") {
tokenStore.clear()
appState.session.isAuthenticated = false
appState.session.jwt = nil
SessionStateStore.clearActiveUser()
appState = AppState()
root = .auth
}
.padding(.horizontal, 20)
@@ -81,6 +81,7 @@ struct ProfileView: View {
struct AddressesView: View {
let message: String?
@Binding var appState: AppState
var selectionMode: Bool = false
@Environment(\.dismiss) var dismiss
@State var isLoading = false
@State var errorMessage: String? = nil
@@ -127,7 +128,23 @@ struct AddressesView: View {
.padding(.top, 24)
} else {
ForEach(Array(addresses.enumerated()), id: \.offset) { index, address in
AddressCard(item: addressToListItem(address, isPrimary: index == 0))
let isSelected: Bool = {
if let selectedId = appState.address.selectedId {
return address.id == selectedId
}
return index == 0
}()
if selectionMode {
Button {
selectAddress(address)
} label: {
AddressCard(item: addressToListItem(address, isPrimary: isSelected))
}
.buttonStyle(.plain)
} else {
AddressCard(item: addressToListItem(address, isPrimary: isSelected))
}
}
}
}
@@ -159,6 +176,7 @@ struct AddressesView: View {
appState.address.latitude = nil
appState.address.longitude = nil
}
SessionStateStore.saveAddress(appState.address)
SnackbarCenter.shared.show(title: "Endereço adicionado com sucesso.", style: .success, icon: "checkmark.seal.fill", duration: 3.0)
}
}
@@ -219,6 +237,26 @@ struct AddressesView: View {
}
}
private func selectAddress(_ address: CustomerAddress) {
appState.address.selectedId = address.id
let label = (address.label ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
appState.address.display = label.isEmpty ? "Defina seu endereco" : label
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
}
SessionStateStore.saveAddress(appState.address)
if selectionMode {
dismiss()
}
}
func addressToListItem(_ address: CustomerAddress, isPrimary: Bool) -> AddressListItem {
let title = address.label?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false ? (address.label ?? "Endereço") : "Endereço"
let line1 = [address.address, address.number]
@@ -266,14 +304,26 @@ struct AddressesView: View {
return
}
addresses = response.result?.addressBook ?? []
if let first = addresses.first {
appState.address.selectedId = first.id
appState.address.display = first.label ?? "Defina seu endereco"
if let lat = first.latLong?.first, let lng = first.latLong?.dropFirst().first {
if let customer = response.result {
appState.profile.id = customer.id
appState.profile.name = customer.name
appState.profile.email = customer.email
appState.profile.phone = customer.phoneNumber ?? appState.profile.phone
SessionStateStore.setActiveUserKey(
SessionStateStore.makeUserKey(profileId: customer.id, email: customer.email)
)
addresses = customer.addressBook ?? []
} else {
addresses = []
}
if let selected = addresses.first(where: { $0.id == appState.address.selectedId }) ?? addresses.first {
appState.address.selectedId = selected.id
appState.address.display = selected.label ?? "Defina seu endereco"
if let lat = selected.latLong?.first, let lng = selected.latLong?.dropFirst().first {
appState.address.latitude = lat
appState.address.longitude = lng
}
SessionStateStore.saveAddress(appState.address)
}
} catch {
errorMessage = error.localizedDescription