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