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

@@ -0,0 +1,12 @@
{
"images" : [
{
"filename" : "placeholder-product.png",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

View File

@@ -6,19 +6,7 @@ struct FeaturedStoreCard: View {
var body: some View { var body: some View {
VStack(alignment: .leading, spacing: 12) { VStack(alignment: .leading, spacing: 12) {
ZStack(alignment: .topLeading) { ZStack(alignment: .topLeading) {
RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous) mediaBlock
.fill(AppColors.brandSoft)
.frame(height: 120)
.overlay(
Circle()
.fill(AppColors.surface)
.frame(width: 64, height: 64)
.overlay(
Image(systemName: store.iconName)
.font(.title2)
.foregroundStyle(AppColors.primary)
)
)
if let promo = store.promoText { if let promo = store.promoText {
Text(promo) Text(promo)
@@ -76,6 +64,58 @@ struct FeaturedStoreCard: View {
.background(AppColors.surface) .background(AppColors.surface)
.clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusXL, style: .continuous)) .clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusXL, style: .continuous))
} }
private var mediaBlock: some View {
ZStack {
RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous)
.fill(AppColors.brandSoft)
mediaImage
}
.frame(height: 120)
.clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous))
}
@ViewBuilder
private var mediaImage: some View {
if let imageURL = store.imageURL,
let url = URL(string: imageURL) {
AsyncImage(url: url) { phase in
switch phase {
case .success(let image):
image
.resizable()
.scaledToFill()
default:
storeIconPlaceholder
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.clipped()
} else {
storeIconPlaceholder
}
}
private var storeIconPlaceholder: some View {
ZStack {
Image("placeholder-product")
.resizable()
.scaledToFill()
.opacity(0.7)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.clipped()
Circle()
.fill(AppColors.surface.opacity(0.92))
.frame(width: 64, height: 64)
.overlay(
Image(systemName: store.iconName)
.font(.title2)
.foregroundStyle(AppColors.primary)
)
}
}
} }
struct FeaturedStoreCardModel: Identifiable { struct FeaturedStoreCardModel: Identifiable {
@@ -88,6 +128,7 @@ struct FeaturedStoreCardModel: Identifiable {
let promoText: String? let promoText: String?
let isFavorite: Bool let isFavorite: Bool
let iconName: String let iconName: String
let imageURL: String?
} }
struct SpecialOfferCard: View { struct SpecialOfferCard: View {

View File

@@ -5,6 +5,10 @@ struct ContentView: View {
@State var selectedTab: MainTab = .home @State var selectedTab: MainTab = .home
private let tokenStore: TokenStore = DefaultTokenStore() private let tokenStore: TokenStore = DefaultTokenStore()
@State var appState = AppState() @State var appState = AppState()
@State var isBootstrappingSession = false
#if os(iOS)
@State private var sessionExpiredObserver: NSObjectProtocol?
#endif
#if os(Android) #if os(Android)
@State var snackbarCenter = SnackbarCenter.shared @State var snackbarCenter = SnackbarCenter.shared
#else #else
@@ -18,9 +22,13 @@ struct ContentView: View {
case .auth: case .auth:
AuthFlowView(root: $root, selectedTab: $selectedTab, tokenStore: tokenStore, appState: $appState) AuthFlowView(root: $root, selectedTab: $selectedTab, tokenStore: tokenStore, appState: $appState)
case .main: case .main:
if isBootstrappingSession {
sessionBootstrapLoadingView
} else {
MainTabView(selectedTab: $selectedTab, root: $root, tokenStore: tokenStore, appState: $appState) MainTabView(selectedTab: $selectedTab, root: $root, tokenStore: tokenStore, appState: $appState)
} }
} }
}
SnackbarOverlay(center: snackbarCenter) SnackbarOverlay(center: snackbarCenter)
} }
@@ -32,48 +40,187 @@ struct ContentView: View {
FiltersModalView() 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 { struct AddressPickerModalView: View {
@Binding var appState: AppState @Binding var appState: AppState
@Binding var selectedTab: MainTab @Binding var selectedTab: MainTab
@Environment(\.dismiss) var dismiss
var body: some View { var body: some View {
NavigationStack { NavigationStack {
VStack(spacing: 12) { AddressesView(
if let message = appState.address.onboardingMessage { message: appState.address.onboardingMessage,
Text(message) appState: $appState,
.font(AppTypography.body) selectionMode: true
.multilineTextAlignment(.center) )
.foregroundStyle(AppColors.textPrimary) .onAppear {
.padding(.horizontal, 16) appState.address.onboardingMessage = nil
.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)
}
.padding(24)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(AppColors.backgroundLight)
} }
} }
} }

View File

@@ -156,6 +156,10 @@
} }
} }
}, },
"Carregando sua sessão..." : {
"comment" : "A loading message displayed while bootstrapping the user's session.",
"isCommentAutoGenerated" : true
},
"Carrinho" : { "Carrinho" : {
"comment" : "A label for the cart section of the app.", "comment" : "A label for the cart section of the app.",
"isCommentAutoGenerated" : true "isCommentAutoGenerated" : true
@@ -314,10 +318,6 @@
"comment" : "A description of the filters feature that will be added to the home screen.", "comment" : "A description of the filters feature that will be added to the home screen.",
"isCommentAutoGenerated" : true "isCommentAutoGenerated" : true
}, },
"Fluxo de endereco sera implementado na etapa de checkout/perfil." : {
"comment" : "A message explaining that the address selection feature will be implemented in the checkout and profile screens.",
"isCommentAutoGenerated" : true
},
"Hello [%@](https://skip.tools)!" : { "Hello [%@](https://skip.tools)!" : {
"comment" : "Welcome tab contents", "comment" : "Welcome tab contents",
"extractionState" : "stale", "extractionState" : "stale",
@@ -385,10 +385,6 @@
"comment" : "A description below the text field where the user inputs their OTP code.", "comment" : "A description below the text field where the user inputs their OTP code.",
"isCommentAutoGenerated" : true "isCommentAutoGenerated" : true
}, },
"Ir para Perfil > Endereços" : {
"comment" : "A button that navigates to the user profile screen when pressed.",
"isCommentAutoGenerated" : true
},
"Já tem uma conta?" : { "Já tem uma conta?" : {
"comment" : "A question displayed below the \"Cadastrar e Receber Código\" button in the registration view. The text is a hyperlink to the login view.", "comment" : "A question displayed below the \"Cadastrar e Receber Código\" button in the registration view. The text is a hyperlink to the login view.",
"isCommentAutoGenerated" : true "isCommentAutoGenerated" : true
@@ -513,6 +509,9 @@
"O que vai querer \npedir hoje?" : { "O que vai querer \npedir hoje?" : {
"comment" : "A title displayed above the search bar in the home view.", "comment" : "A title displayed above the search bar in the home view.",
"isCommentAutoGenerated" : true "isCommentAutoGenerated" : true
},
"O que vai querer pedir hoje?\n " : {
}, },
"para %@" : { "para %@" : {
"comment" : "A text label displaying the email address to which the one-time password (OTP) was sent. The text is truncated to fit within one line.", "comment" : "A text label displaying the email address to which the one-time password (OTP) was sent. The text is truncated to fit within one line.",
@@ -618,10 +617,6 @@
} }
} }
}, },
"Selecionar endereco" : {
"comment" : "A title for the address picker modal view.",
"isCommentAutoGenerated" : true
},
"Settings" : { "Settings" : {
"comment" : "Tab bar item title for the Settings tab", "comment" : "Tab bar item title for the Settings tab",
"extractionState" : "stale", "extractionState" : "stale",

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

View File

@@ -39,6 +39,11 @@ enum NetworkError: Error, LocalizedError {
} }
} }
private struct ApiErrorDescriptor {
let code: String?
let message: String?
}
struct ApiRequest: Sendable { struct ApiRequest: Sendable {
let path: String let path: String
let method: String let method: String
@@ -166,6 +171,10 @@ private extension ApiClient {
let nsError = error as NSError let nsError = error as NSError
let apiMessage = serverMessage(from: nsError) let apiMessage = serverMessage(from: nsError)
let payload = serverPayload(from: nsError)
if isSessionExpiredPayload(code: payload?.code, message: payload?.message ?? apiMessage) {
return .unauthorized(payload?.message ?? apiMessage)
}
switch nsError.code { switch nsError.code {
case 401, 403: case 401, 403:
@@ -228,6 +237,11 @@ private extension ApiClient {
throw NetworkError.invalidResponse throw NetworkError.invalidResponse
} }
let payload = serverPayload(from: data)
if isSessionExpiredPayload(code: payload?.code, message: payload?.message) {
throw NetworkError.unauthorized(payload?.message)
}
if http.statusCode == 429 { if http.statusCode == 429 {
let retryAfter = Int(http.value(forHTTPHeaderField: "Retry-After") ?? "") let retryAfter = Int(http.value(forHTTPHeaderField: "Retry-After") ?? "")
throw NetworkError.rateLimited(retryAfter) throw NetworkError.rateLimited(retryAfter)
@@ -283,19 +297,44 @@ private extension ApiClient {
} }
func serverMessage(from data: Data) -> String? { func serverMessage(from data: Data) -> String? {
let payload = serverPayload(from: data)
if let message = payload?.message, message.isEmpty == false {
return message
}
if let code = payload?.code, code.isEmpty == false {
return "Erro: \(code)"
}
return String(data: data, encoding: .utf8)
}
func serverPayload(from data: Data) -> ApiErrorDescriptor? {
if let envelope = try? JSONDecoder().decode(ApiEnvelope<EmptyResult>.self, from: data) { if let envelope = try? JSONDecoder().decode(ApiEnvelope<EmptyResult>.self, from: data) {
return envelope.message return ApiErrorDescriptor(code: envelope.code, message: envelope.message)
} }
if let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any] { if let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any] {
if let message = object["message"] as? String { let code = object["code"] as? String
return message let message = object["message"] as? String
} if code != nil || message != nil {
if let code = object["code"] as? String { return ApiErrorDescriptor(code: code, message: message)
return "Erro: \(code)"
} }
} }
return String(data: data, encoding: .utf8)
return nil
}
func isSessionExpiredPayload(code: String?, message: String?) -> Bool {
let normalizedCode = (code ?? "").lowercased()
let normalizedMessage = (message ?? "").lowercased()
if normalizedCode.contains("auth") || normalizedCode.contains("token") || normalizedCode.contains("unauthorized") {
return true
}
if normalizedMessage.contains("token") && (normalizedMessage.contains("expir") || normalizedMessage.contains("invalid") || normalizedMessage.contains("sess")) {
return true
}
return false
} }
#if canImport(LCEssentials) && os(iOS) #if canImport(LCEssentials) && os(iOS)
@@ -318,6 +357,15 @@ private extension ApiClient {
return nil return nil
} }
func serverPayload(from error: NSError) -> ApiErrorDescriptor? {
if let reason = error.userInfo[NSLocalizedFailureReasonErrorKey] as? String,
let data = reason.data(using: .utf8),
let payload = serverPayload(from: data) {
return payload
}
return nil
}
#endif #endif
func buildURL(path: String, query: [URLQueryItem]) throws -> URL { func buildURL(path: String, query: [URLQueryItem]) throws -> URL {

View File

@@ -32,14 +32,40 @@ final class ApiService {
return try await client.send(req) return try await client.send(req)
} catch let error as NetworkError { } catch let error as NetworkError {
if case .unauthorized(let message) = error { if case .unauthorized(let message) = error {
tokenStore.clear() expireSession(message)
NotificationCenter.default.post(name: .sessionExpired, object: message)
throw ApiServiceError.sessionExpired(message) throw ApiServiceError.sessionExpired(message)
} }
throw error throw error
} }
} }
private func sendEnvelope<T: Decodable>(_ req: ApiRequest) async throws -> ApiEnvelope<T> {
let envelope: ApiEnvelope<T> = try await send(req)
if isSessionExpiredEnvelope(envelope) {
expireSession(envelope.message)
throw ApiServiceError.sessionExpired(envelope.message)
}
return envelope
}
private func isSessionExpiredEnvelope<T>(_ envelope: ApiEnvelope<T>) -> Bool {
guard envelope.error else { return false }
let code = (envelope.code ?? "").lowercased()
let message = (envelope.message ?? "").lowercased()
if code.contains("auth") || code.contains("token") || code.contains("unauthorized") {
return true
}
if message.contains("token") && (message.contains("expir") || message.contains("invalid") || message.contains("sess")) {
return true
}
return false
}
private func expireSession(_ message: String?) {
tokenStore.clear()
NotificationCenter.default.post(name: .sessionExpired, object: message)
}
// MARK: - Auth // MARK: - Auth
func registerCustomer(name: String, email: String, phoneNumber: String, birthDate: String? = nil) async throws -> ApiEnvelope<RegistrationResult> { func registerCustomer(name: String, email: String, phoneNumber: String, birthDate: String? = nil) async throws -> ApiEnvelope<RegistrationResult> {
@@ -53,19 +79,19 @@ final class ApiService {
} }
let body = try JSONEncoder().encode(payload) let body = try JSONEncoder().encode(payload)
let req = ApiRequest(path: "/api/customer", method: "POST", module: .customer, requiresAuth: false, body: body) let req = ApiRequest(path: "/api/customer", method: "POST", module: .customer, requiresAuth: false, body: body)
return try await send(req) return try await sendEnvelope(req)
} }
func requestOtp(email: String, phoneNumber: String) async throws -> ApiEnvelope<EmptyResult> { func requestOtp(email: String, phoneNumber: String) async throws -> ApiEnvelope<EmptyResult> {
let body = try makeLoginBody(email: email, phoneNumber: phoneNumber, otp: nil) let body = try makeLoginBody(email: email, phoneNumber: phoneNumber, otp: nil)
let req = ApiRequest(path: "/api/customer/login", method: "POST", module: .customer, requiresAuth: false, body: body) let req = ApiRequest(path: "/api/customer/login", method: "POST", module: .customer, requiresAuth: false, body: body)
return try await send(req) return try await sendEnvelope(req)
} }
func validateOtp(email: String, phoneNumber: String, otp: String) async throws -> ApiEnvelope<LoginResult> { func validateOtp(email: String, phoneNumber: String, otp: String) async throws -> ApiEnvelope<LoginResult> {
let body = try makeLoginBody(email: email, phoneNumber: phoneNumber, otp: otp) let body = try makeLoginBody(email: email, phoneNumber: phoneNumber, otp: otp)
let req = ApiRequest(path: "/api/customer/login", method: "POST", module: .customer, requiresAuth: false, body: body) let req = ApiRequest(path: "/api/customer/login", method: "POST", module: .customer, requiresAuth: false, body: body)
let response: ApiEnvelope<LoginResult> = try await send(req) let response: ApiEnvelope<LoginResult> = try await sendEnvelope(req)
if let token = response.result?.token { if let token = response.result?.token {
tokenStore.jwt = token tokenStore.jwt = token
} }
@@ -97,7 +123,7 @@ final class ApiService {
func profile() async throws -> ApiEnvelope<CustomerProfile> { func profile() async throws -> ApiEnvelope<CustomerProfile> {
let req = ApiRequest(path: "/api/customer/profile", method: "GET", module: .customer, requiresAuth: true) let req = ApiRequest(path: "/api/customer/profile", method: "GET", module: .customer, requiresAuth: true)
return try await send(req) return try await sendEnvelope(req)
} }
func addCustomerAddress(_ address: CustomerAddress) async throws -> ApiEnvelope<CustomerProfile> { func addCustomerAddress(_ address: CustomerAddress) async throws -> ApiEnvelope<CustomerProfile> {
@@ -112,7 +138,7 @@ final class ApiService {
let payload = CustomerProfileUpdatePayload(addressBook: addressBook) let payload = CustomerProfileUpdatePayload(addressBook: addressBook)
let body = try JSONEncoder().encode(payload) let body = try JSONEncoder().encode(payload)
let req = ApiRequest(path: "/api/customer/\(customer.id)", method: "POST", module: .customer, requiresAuth: true, body: body) let req = ApiRequest(path: "/api/customer/\(customer.id)", method: "POST", module: .customer, requiresAuth: true, body: body)
return try await send(req) return try await sendEnvelope(req)
} }
func lookupZipCode(_ zipCode: String) async throws -> ApiEnvelope<CepLookupResult> { func lookupZipCode(_ zipCode: String) async throws -> ApiEnvelope<CepLookupResult> {
@@ -128,16 +154,22 @@ final class ApiService {
} }
let req = ApiRequest(path: "/api/public/cep/\(formatted)", method: "GET", module: .none, requiresAuth: true) let req = ApiRequest(path: "/api/public/cep/\(formatted)", method: "GET", module: .none, requiresAuth: true)
return try await send(req) return try await sendEnvelope(req)
} }
// MARK: - Stores // MARK: - Stores
func listStores(lat: Double, lng: Double, category: String? = nil, search: String? = nil) async throws -> ApiEnvelope<[StoreSummary]> { func listPublicCategories() async throws -> ApiEnvelope<[PublicCategory]> {
var items = [ let req = ApiRequest(path: "/api/public/categories", method: "GET", module: .none, requiresAuth: false)
URLQueryItem(name: "lat", value: String(lat)), return try await sendEnvelope(req)
URLQueryItem(name: "lng", value: String(lng)) }
]
func listStores(lat: Double? = nil, lng: Double? = nil, category: String? = nil, search: String? = nil) async throws -> ApiEnvelope<[StoreSummary]> {
var items: [URLQueryItem] = []
if let lat, let lng {
items.append(URLQueryItem(name: "lat", value: String(lat)))
items.append(URLQueryItem(name: "lng", value: String(lng)))
}
if let category { if let category {
items.append(URLQueryItem(name: "category", value: category)) items.append(URLQueryItem(name: "category", value: category))
} }
@@ -145,7 +177,7 @@ final class ApiService {
items.append(URLQueryItem(name: "search", value: search)) items.append(URLQueryItem(name: "search", value: search))
} }
let req = ApiRequest(path: "/api/app/stores", method: "GET", module: .app, requiresAuth: true, queryItems: items) let req = ApiRequest(path: "/api/app/stores", method: "GET", module: .app, requiresAuth: true, queryItems: items)
return try await send(req) return try await sendEnvelope(req)
} }
} }
@@ -222,6 +254,12 @@ struct StoreSummary: Decodable {
let statusLabel: String? let statusLabel: String?
} }
struct PublicCategory: Decodable {
let id: String
let name: String
let icon: String?
}
struct CustomerProfileUpdatePayload: Encodable { struct CustomerProfileUpdatePayload: Encodable {
let addressBook: [CustomerAddressPayload] let addressBook: [CustomerAddressPayload]

View File

@@ -0,0 +1,139 @@
import Foundation
private struct PersistedAddressState: Codable {
let selectedId: String?
let display: String
let latitude: Double?
let longitude: Double?
}
enum SessionStateStore {
private static let legacyAddressKey = "session.address.state.v1"
private static let addressKeyPrefix = "session.address.state.v2."
private static let activeUserKey = "session.active.user.v1"
static func makeUserKey(profileId: String?, email: String?) -> String? {
let id = (profileId ?? "")
.trimmingCharacters(in: .whitespacesAndNewlines)
.lowercased()
if id.isEmpty == false {
return "id:\(id)"
}
let mail = (email ?? "")
.trimmingCharacters(in: .whitespacesAndNewlines)
.lowercased()
if mail.isEmpty == false {
return "email:\(mail)"
}
return nil
}
static func setActiveUserKey(_ userKey: String?) {
let trimmed = (userKey ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed.isEmpty {
UserDefaults.standard.removeObject(forKey: activeUserKey)
} else {
UserDefaults.standard.set(trimmed, forKey: activeUserKey)
}
}
static func loadActiveUserKey() -> String? {
let value = UserDefaults.standard.string(forKey: activeUserKey)?
.trimmingCharacters(in: .whitespacesAndNewlines)
if let value, value.isEmpty == false {
return value
}
return nil
}
private static func addressStorageKey(for userKey: String?) -> String {
let key = (userKey ?? loadActiveUserKey() ?? "anonymous")
.trimmingCharacters(in: .whitespacesAndNewlines)
let safe = key.replacingOccurrences(of: " ", with: "_")
return addressKeyPrefix + safe
}
static func loadAddress() -> AddressState? {
let defaults = UserDefaults.standard
let activeKey = loadActiveUserKey()
let scopedKey = addressStorageKey(for: activeKey)
if let data = defaults.data(forKey: scopedKey),
let decoded = try? JSONDecoder().decode(PersistedAddressState.self, from: data) {
return AddressState(
selectedId: decoded.selectedId,
display: decoded.display.isEmpty ? "Defina seu endereco" : decoded.display,
latitude: decoded.latitude,
longitude: decoded.longitude,
onboardingMessage: nil
)
}
// Backward-compatible fallback for data persisted before user scoping.
let anonymousKey = addressStorageKey(for: "anonymous")
if let data = defaults.data(forKey: anonymousKey),
let decoded = try? JSONDecoder().decode(PersistedAddressState.self, from: data) {
let recovered = AddressState(
selectedId: decoded.selectedId,
display: decoded.display.isEmpty ? "Defina seu endereco" : decoded.display,
latitude: decoded.latitude,
longitude: decoded.longitude,
onboardingMessage: nil
)
// Migrate anonymous cache into the current active user namespace.
if let activeKey, activeKey.isEmpty == false {
let payload = PersistedAddressState(
selectedId: recovered.selectedId,
display: recovered.display,
latitude: recovered.latitude,
longitude: recovered.longitude
)
if let migratedData = try? JSONEncoder().encode(payload) {
defaults.set(migratedData, forKey: scopedKey)
}
}
return recovered
}
guard let data = defaults.data(forKey: legacyAddressKey),
let decoded = try? JSONDecoder().decode(PersistedAddressState.self, from: data) else {
return nil
}
let migrated = AddressState(
selectedId: decoded.selectedId,
display: decoded.display.isEmpty ? "Defina seu endereco" : decoded.display,
latitude: decoded.latitude,
longitude: decoded.longitude,
onboardingMessage: nil
)
saveAddress(migrated)
defaults.removeObject(forKey: legacyAddressKey)
return migrated
}
static func saveAddress(_ state: AddressState) {
let payload = PersistedAddressState(
selectedId: state.selectedId,
display: state.display,
latitude: state.latitude,
longitude: state.longitude
)
guard let data = try? JSONEncoder().encode(payload) else { return }
UserDefaults.standard.set(data, forKey: addressStorageKey(for: nil))
}
static func clearAddress() {
let defaults = UserDefaults.standard
defaults.removeObject(forKey: addressStorageKey(for: nil))
defaults.removeObject(forKey: legacyAddressKey)
}
static func clearActiveUser() {
UserDefaults.standard.removeObject(forKey: activeUserKey)
}
}

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) SnackbarCenter.shared.show(title: "Login realizado com sucesso.", style: .success, icon: "checkmark.seal.fill", duration: 2.5)
appState.session.isAuthenticated = true appState.session.isAuthenticated = true
appState.session.jwt = response.result?.token appState.session.jwt = response.result?.token
appState.profile.email = email hydrateUserState(from: response.result?.customer)
routeAfterLogin() routeAfterLogin()
} }
} catch { } catch {
@@ -221,6 +221,44 @@ struct OtpView: View {
root = .main 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 { private func hasConfiguredAddress() -> Bool {
if appState.address.selectedId != nil { if appState.address.selectedId != nil {
return true return true

View File

@@ -1,6 +1,7 @@
import Foundation import Foundation
import SwiftUI import SwiftUI
#if os(iOS) #if os(iOS)
import LCEssentials
import UIKit import UIKit
#endif #endif
@@ -9,7 +10,7 @@ struct HomeView: View {
@State var searchText = "" @State var searchText = ""
@State var selectedCategory = "all" @State var selectedCategory = "all"
@State var categories: [CategoryModel] = [ @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 scrollOffset: CGFloat = 0
@State var collapseBaseOffset: CGFloat = 0 @State var collapseBaseOffset: CGFloat = 0
@@ -120,7 +121,8 @@ struct HomeView: View {
Task { Task {
await bootstrapStoresFlow( await bootstrapStoresFlow(
forceLocationRefresh: true, forceLocationRefresh: true,
category: selectedCategory == "all" ? nil : selectedCategory category: selectedCategory == "all" ? nil : selectedCategory,
refreshCategories: true
) )
} }
} }
@@ -166,13 +168,15 @@ struct HomeView: View {
.foregroundStyle(AppColors.brandDark) .foregroundStyle(AppColors.brandDark)
) )
VStack(alignment: .leading, spacing: 4) { VStack(alignment: .center, spacing: 4) {
Text("DELIVERY LOCATION") Text("DELIVERY LOCATION")
.font(AppTypography.overline) .font(AppTypography.overline)
.tracking(AppTypography.captionLetterSpacing) .tracking(AppTypography.captionLetterSpacing)
.foregroundStyle(AppColors.brandSoft) .foregroundStyle(AppColors.brandSoft)
.multilineTextAlignment(.center)
Button { Button {
appState.address.onboardingMessage = nil
appState.activeModal = .addressPicker appState.activeModal = .addressPicker
} label: { } label: {
HStack(spacing: 6) { HStack(spacing: 6) {
@@ -186,8 +190,7 @@ struct HomeView: View {
} }
.buttonStyle(.plain) .buttonStyle(.plain)
} }
.frame(maxWidth: .infinity, alignment: .center)
Spacer()
Circle() Circle()
.fill(Color.white.opacity(0.18)) .fill(Color.white.opacity(0.18))
@@ -200,11 +203,19 @@ struct HomeView: View {
.opacity(topRowOpacity) .opacity(topRowOpacity)
.offset(y: collapseProgress * -12) .offset(y: collapseProgress * -12)
if UIDevice().modelName.lowercased().contains("se") || UIDevice().modelName.lowercased().contains("16e") {
Text("O que vai querer \npedir hoje?") Text("O que vai querer \npedir hoje?")
.font(AppTypography.heading2) .font(AppTypography.heading2)
.foregroundStyle(AppColors.textInverse) .foregroundStyle(AppColors.textInverse)
.opacity(titleOpacity) .opacity(titleOpacity)
.offset(y: collapseProgress * -20) .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) { SearchBar(placeholder: "Search menu, restaurant or craving", text: $searchText) {
appState.activeModal = .filters appState.activeModal = .filters
@@ -240,6 +251,7 @@ struct HomeView: View {
CategoryChip( CategoryChip(
title: category.title, title: category.title,
systemIcon: category.systemIcon, systemIcon: category.systemIcon,
emojiIcon: category.emojiIcon,
isActive: category.id == selectedCategory isActive: category.id == selectedCategory
) )
.onTapGesture { .onTapGesture {
@@ -293,10 +305,27 @@ struct HomeView: View {
category: store.category ?? "Loja", category: store.category ?? "Loja",
promoText: nil, promoText: nil,
isFavorite: appState.favorites.storeIds.contains(store.id), 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 { private func formatDistance(_ distance: Double?) -> String {
guard let distance else { return "Distância indisponível" } guard let distance else { return "Distância indisponível" }
if distance >= 1 { if distance >= 1 {
@@ -314,7 +343,10 @@ struct HomeView: View {
isLoadingStores = true isLoadingStores = true
storesError = nil 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 isLoadingStores = false
stores = [] stores = []
storesError = "Defina um endereço para visualizar os estabelecimentos mais próximos." storesError = "Defina um endereço para visualizar os estabelecimentos mais próximos."
@@ -324,7 +356,11 @@ struct HomeView: View {
} }
do { 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 isLoadingStores = false
if response.error { if response.error {
stores = [] stores = []
@@ -333,8 +369,8 @@ struct HomeView: View {
} }
let results = response.result ?? [] let results = response.result ?? []
stores = results stores = results
if refreshCategories { if refreshCategories || (category == nil && categories.count <= 1) {
categories = buildCategories(from: results) await loadHomeCategories(withFallbackStores: results)
if categories.contains(where: { $0.id == selectedCategory }) == false { if categories.contains(where: { $0.id == selectedCategory }) == false {
selectedCategory = "all" selectedCategory = "all"
} }
@@ -349,7 +385,7 @@ struct HomeView: View {
private func buildCategories(from stores: [StoreSummary]) -> [CategoryModel] { private func buildCategories(from stores: [StoreSummary]) -> [CategoryModel] {
var unique: [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>() var seen = Set<String>()
@@ -359,11 +395,59 @@ struct HomeView: View {
let dedupe = raw.lowercased() let dedupe = raw.lowercased()
if seen.contains(dedupe) { continue } if seen.contains(dedupe) { continue }
seen.insert(dedupe) 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 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 { private func categoryIcon(for category: String) -> String {
let value = category.folding(options: [.diacriticInsensitive, .caseInsensitive], locale: .current).lowercased() let value = category.folding(options: [.diacriticInsensitive, .caseInsensitive], locale: .current).lowercased()
if value.contains("pizza") { return "takeoutbag.and.cup.and.straw" } if value.contains("pizza") { return "takeoutbag.and.cup.and.straw" }
@@ -440,18 +524,25 @@ struct HomeView: View {
struct CategoryModel: Identifiable { struct CategoryModel: Identifiable {
let id: String let id: String
let title: String let title: String
let systemIcon: String let systemIcon: String?
let emojiIcon: String?
} }
struct CategoryChip: View { struct CategoryChip: View {
let title: String let title: String
let systemIcon: String let systemIcon: String?
let emojiIcon: String?
let isActive: Bool let isActive: Bool
var body: some View { var body: some View {
HStack(spacing: 8) { HStack(spacing: 8) {
if let emojiIcon, emojiIcon.isEmpty == false {
Text(emojiIcon)
.font(.body)
} else if let systemIcon, systemIcon.isEmpty == false {
Image(systemName: systemIcon) Image(systemName: systemIcon)
.font(.caption) .font(.caption)
}
Text(title) Text(title)
.font(AppTypography.heading3) .font(AppTypography.heading3)
} }

View File

@@ -32,8 +32,8 @@ struct ProfileView: View {
SecondaryButton(title: "Sair") { SecondaryButton(title: "Sair") {
tokenStore.clear() tokenStore.clear()
appState.session.isAuthenticated = false SessionStateStore.clearActiveUser()
appState.session.jwt = nil appState = AppState()
root = .auth root = .auth
} }
.padding(.horizontal, 20) .padding(.horizontal, 20)
@@ -81,6 +81,7 @@ struct ProfileView: View {
struct AddressesView: View { struct AddressesView: View {
let message: String? let message: String?
@Binding var appState: AppState @Binding var appState: AppState
var selectionMode: Bool = false
@Environment(\.dismiss) var dismiss @Environment(\.dismiss) var dismiss
@State var isLoading = false @State var isLoading = false
@State var errorMessage: String? = nil @State var errorMessage: String? = nil
@@ -127,7 +128,23 @@ struct AddressesView: View {
.padding(.top, 24) .padding(.top, 24)
} else { } else {
ForEach(Array(addresses.enumerated()), id: \.offset) { index, address in 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.latitude = nil
appState.address.longitude = 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) 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 { func addressToListItem(_ address: CustomerAddress, isPrimary: Bool) -> AddressListItem {
let title = address.label?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false ? (address.label ?? "Endereço") : "Endereço" let title = address.label?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false ? (address.label ?? "Endereço") : "Endereço"
let line1 = [address.address, address.number] let line1 = [address.address, address.number]
@@ -266,14 +304,26 @@ struct AddressesView: View {
return return
} }
addresses = response.result?.addressBook ?? [] if let customer = response.result {
if let first = addresses.first { appState.profile.id = customer.id
appState.address.selectedId = first.id appState.profile.name = customer.name
appState.address.display = first.label ?? "Defina seu endereco" appState.profile.email = customer.email
if let lat = first.latLong?.first, let lng = first.latLong?.dropFirst().first { 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.latitude = lat
appState.address.longitude = lng appState.address.longitude = lng
} }
SessionStateStore.saveAddress(appState.address)
} }
} catch { } catch {
errorMessage = error.localizedDescription errorMessage = error.localizedDescription