login flow
This commit is contained in:
12
pedi-foods/Darwin/Assets.xcassets/placeholder-product.imageset/Contents.json
vendored
Normal file
12
pedi-foods/Darwin/Assets.xcassets/placeholder-product.imageset/Contents.json
vendored
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{
|
||||||
|
"filename" : "placeholder-product.png",
|
||||||
|
"idiom" : "universal"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
pedi-foods/Darwin/Assets.xcassets/placeholder-product.imageset/placeholder-product.png
vendored
Normal file
BIN
pedi-foods/Darwin/Assets.xcassets/placeholder-product.imageset/placeholder-product.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 59 KiB |
@@ -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 {
|
||||||
|
|||||||
@@ -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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
BIN
pedi-foods/Sources/PediFoods/Resources/placeholder-product.png
Normal file
BIN
pedi-foods/Sources/PediFoods/Resources/placeholder-product.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 59 KiB |
@@ -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 {
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|
||||||
|
|||||||
139
pedi-foods/Sources/PediFoods/Services/SessionStateStore.swift
Normal file
139
pedi-foods/Sources/PediFoods/Services/SessionStateStore.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user