diff --git a/Darwin/Entitlements.plist b/Darwin/Entitlements.plist
index 6631ffa..70084b4 100644
--- a/Darwin/Entitlements.plist
+++ b/Darwin/Entitlements.plist
@@ -2,5 +2,7 @@
+ com.apple.developer.devicecheck.appattest-environment
+ development
diff --git a/Darwin/fastlane/metadata/en-US/support_url.txt b/Darwin/fastlane/metadata/en-US/support_url.txt
index fcfa1f7..7ef4109 100644
--- a/Darwin/fastlane/metadata/en-US/support_url.txt
+++ b/Darwin/fastlane/metadata/en-US/support_url.txt
@@ -1 +1 @@
-https://example.org/support/
+https://pedifoods.com.br/support
diff --git a/Sources/PediFoods/ContentView.swift b/Sources/PediFoods/ContentView.swift
index 3930ebf..809c4de 100644
--- a/Sources/PediFoods/ContentView.swift
+++ b/Sources/PediFoods/ContentView.swift
@@ -2,7 +2,7 @@ import Foundation
import SwiftUI
struct ContentView: View {
- @State var root: RootFlow = DefaultTokenStore().jwt == nil ? .auth : .main
+ @State var root: RootFlow = .main
@State var selectedTab: MainTab = .home
private let tokenStore: TokenStore = DefaultTokenStore()
@State var appState = AppState()
@@ -167,20 +167,35 @@ struct ContentView: View {
appState.cart = cachedCart
}
+ // Anonymous session: the account address cache above doesn't apply —
+ // restore the "ENTREGAR EM" label from the state/city picked via the
+ // public locator (GuestLocationStore persists this in Keychain across
+ // launches on its own; this just resyncs the display label with it).
+ if appState.session.isAuthenticated == false,
+ let guestState = GuestLocationStore.shared.selectedState,
+ let guestCity = GuestLocationStore.shared.selectedCity {
+ appState.address.display = "\(guestCity), \(guestState)"
+ dismissAddressPickerIfAddressExists()
+ }
+
// Always refresh profile when authenticated.
// This keeps profile/address/cart scope consistent after relogin
// and avoids stale local state during checkout payload generation.
- do {
- let response = try await ApiService().profile()
- if response.error == false, let customer = response.result {
- hydrateAppState(with: customer)
+ // Anonymous browsing has no session to refresh, so skip the call
+ // entirely rather than let it 401 and force-logout a guest.
+ if appState.session.isAuthenticated {
+ 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
}
- } catch let error as ApiServiceError {
- if case .sessionExpired = error {
- forceLogoutToStart()
- }
- } catch {
- // Keep local state when backend refresh fails transiently
}
await refreshFeatureFlags(forceRefresh: false)
@@ -418,13 +433,17 @@ struct AddressPickerModalView: View {
var body: some View {
NavigationStack {
- AddressesView(
- message: appState.address.onboardingMessage,
- appState: $appState,
- selectionMode: true
- )
- .onAppear {
- appState.address.onboardingMessage = nil
+ if appState.session.isAuthenticated {
+ AddressesView(
+ message: appState.address.onboardingMessage,
+ appState: $appState,
+ selectionMode: true
+ )
+ .onAppear {
+ appState.address.onboardingMessage = nil
+ }
+ } else {
+ PublicLocationPickerView(appState: $appState)
}
}
}
diff --git a/Sources/PediFoods/Resources/Localizable.xcstrings b/Sources/PediFoods/Resources/Localizable.xcstrings
index e776746..b1307e2 100644
--- a/Sources/PediFoods/Resources/Localizable.xcstrings
+++ b/Sources/PediFoods/Resources/Localizable.xcstrings
@@ -456,6 +456,12 @@
"Entrar" : {
"comment" : "A link that directs the user to the login screen.",
"isCommentAutoGenerated" : true
+ },
+ "Entrar ou Cadastrar" : {
+
+ },
+ "Entre na sua conta" : {
+
},
"Entrega" : {
"comment" : "A text describing delivery mode.",
@@ -482,9 +488,18 @@
"Escolha o tamanho da sua fome" : {
"comment" : "A label displayed below the pizza size selection.",
"isCommentAutoGenerated" : true
+ },
+ "Escolha seu estado" : {
+
},
"Escolha seu sabor" : {
+ },
+ "Escolha sua cidade" : {
+
+ },
+ "Essa ação é permanente. Seus dados de perfil, endereços, cartões e favoritos serão excluídos e não poderão ser recuperados." : {
+
},
"Este sabor não possui adicionais." : {
"comment" : "A message displayed when a pizza flavor does not have any add-ons.",
@@ -493,6 +508,15 @@
"Excluir" : {
"comment" : "A button label that translates to \"Delete\".",
"isCommentAutoGenerated" : true
+ },
+ "Excluir Conta" : {
+
+ },
+ "Excluir sua conta?" : {
+
+ },
+ "Faça login ou cadastre-se para ver seu perfil, pedidos e endereços." : {
+
},
"Favorite" : {
"comment" : "Item editor title label for marking the item as a favorite",
diff --git a/Sources/PediFoods/Services/ApiClient.swift b/Sources/PediFoods/Services/ApiClient.swift
index cdc919a..0fcae62 100644
--- a/Sources/PediFoods/Services/ApiClient.swift
+++ b/Sources/PediFoods/Services/ApiClient.swift
@@ -57,19 +57,30 @@ struct ApiRequest: Sendable {
let requiresAuth: Bool
let queryItems: [URLQueryItem]
let body: Data?
+ /// Overrides `ApiConfig.baseURL` (Atomenta) for this single request — used
+ /// to reach the PediFoods BFF (`ApiConfig.pediFoodsBFFURL`) instead.
+ let baseURLOverride: URL?
+ /// Sent as `Authorization: Bearer ` instead of the customer JWT.
+ /// Used for guest-session-authenticated public locator calls, which must
+ /// never touch `TokenStore`'s customer session.
+ let customBearerToken: String?
init(path: String,
method: String = "GET",
module: ApiModule = .none,
requiresAuth: Bool = true,
queryItems: [URLQueryItem] = [],
- body: Data? = nil) {
+ body: Data? = nil,
+ baseURLOverride: URL? = nil,
+ customBearerToken: String? = nil) {
self.path = path
self.method = method
self.module = module
self.requiresAuth = requiresAuth
self.queryItems = queryItems
self.body = body
+ self.baseURLOverride = baseURLOverride
+ self.customBearerToken = customBearerToken
}
}
@@ -120,7 +131,7 @@ final class ApiClient: @unchecked Sendable {
private extension ApiClient {
#if canImport(LCEssentials) && os(iOS)
func sendWithLCEssentials(_ request: ApiRequest) async throws -> T {
- let urlString = try buildURL(path: request.path, query: request.queryItems).absoluteString
+ let urlString = try buildURL(path: request.path, query: request.queryItems, baseURL: request.baseURLOverride ?? ApiConfig.baseURL).absoluteString
let method = request.method
let headers = buildHeaders(for: request)
let params = request.body
@@ -228,7 +239,7 @@ private extension ApiClient {
#endif
func sendWithURLSession(_ request: ApiRequest) async throws -> T {
- let url = try buildURL(path: request.path, query: request.queryItems)
+ let url = try buildURL(path: request.path, query: request.queryItems, baseURL: request.baseURLOverride ?? ApiConfig.baseURL)
var urlRequest = URLRequest(url: url)
urlRequest.httpMethod = request.method
urlRequest.httpBody = request.body
@@ -314,7 +325,9 @@ private extension ApiClient {
if let token = ApiConfig.token(for: request.module) {
headers["Atomenta-Token"] = token
}
- if request.requiresAuth, let jwt = tokenStore.jwt {
+ if let customBearerToken = request.customBearerToken {
+ headers["Authorization"] = "Bearer \(customBearerToken)"
+ } else if request.requiresAuth, let jwt = tokenStore.jwt {
headers["Authorization"] = "Bearer \(jwt)"
}
return headers
@@ -474,8 +487,8 @@ private extension ApiClient {
return trimmed
}
- func buildURL(path: String, query: [URLQueryItem]) throws -> URL {
- guard var components = URLComponents(url: ApiConfig.baseURL, resolvingAgainstBaseURL: false) else {
+ func buildURL(path: String, query: [URLQueryItem], baseURL: URL) throws -> URL {
+ guard var components = URLComponents(url: baseURL, resolvingAgainstBaseURL: false) else {
throw NetworkError.invalidURL
}
components.path = components.path.appending(path)
diff --git a/Sources/PediFoods/Services/ApiConfig.swift b/Sources/PediFoods/Services/ApiConfig.swift
index 4c6366a..028105c 100644
--- a/Sources/PediFoods/Services/ApiConfig.swift
+++ b/Sources/PediFoods/Services/ApiConfig.swift
@@ -9,6 +9,11 @@ enum ApiModule: Sendable {
}
enum ApiConfig {
+ // Atomenta directly — customer/store/cards/orders/addresses/etc. Only
+ // the public locator (session, locations, categories, stores-by-location,
+ // store detail) goes through pediFoodsBFFURL below via explicit
+ // baseURLOverride on those specific requests, per
+ // docs/plans/app-migrate-atomenta-calls-to-pedifoods-bff.md.
static var baseURL: URL {
let raw = ProcessInfo.processInfo.environment["ATOMENTA_API_URL"] ?? "https://atomenta.com.br"
return URL(string: raw) ?? URL(string: "https://atomenta.com.br")!
@@ -19,6 +24,15 @@ enum ApiConfig {
return URL(string: raw) ?? URL(string: "http://localhost:8787")!
}
+ /// BFF (`PediFoods_web`) base URL. The public store locator (guest session,
+ /// states/cities/stores-by-location, store detail) must go through here —
+ /// never call `baseURL` (Atomenta) directly for these, per
+ /// docs/plans/app-migrate-atomenta-calls-to-pedifoods-bff.md.
+ static var pediFoodsBFFURL: URL {
+ let raw = ProcessInfo.processInfo.environment["PEDIFOODS_BFF_URL"] ?? "https://pedifoods.com.br"
+ return URL(string: raw) ?? URL(string: "https://pedifoods.com.br")!
+ }
+
static var featureControlEnvironment: String {
let raw = ProcessInfo.processInfo.environment["FEATURE_CONTROL_ENVIRONMENT"] ?? "production"
let clean = raw.trimmingCharacters(in: .whitespacesAndNewlines)
diff --git a/Sources/PediFoods/Services/ApiService.swift b/Sources/PediFoods/Services/ApiService.swift
index 5d55a62..2eba839 100644
--- a/Sources/PediFoods/Services/ApiService.swift
+++ b/Sources/PediFoods/Services/ApiService.swift
@@ -35,7 +35,7 @@ final class ApiService {
do {
return try await client.send(req)
} catch let error as NetworkError {
- if case .unauthorized(let message) = error {
+ if canTriggerSessionExpiry(for: req), case .unauthorized(let message) = error {
expireSession(message)
throw ApiServiceError.sessionExpired(message)
}
@@ -45,13 +45,22 @@ final class ApiService {
private func sendEnvelope(_ req: ApiRequest) async throws -> ApiEnvelope {
let envelope: ApiEnvelope = try await send(req)
- if isSessionExpiredEnvelope(envelope) {
+ if canTriggerSessionExpiry(for: req), isSessionExpiredEnvelope(envelope) {
expireSession(envelope.message)
throw ApiServiceError.sessionExpired(envelope.message)
}
return envelope
}
+ /// A request that never carried the customer JWT (public/unauthenticated
+ /// calls) can never mean *the customer's* session expired — an unrelated
+ /// error (e.g. Atomenta's module-token check) must not force-logout a
+ /// user, anonymous or not, just because its error code happens to
+ /// contain the substring "token". See app-migrate-atomenta-calls-to-pedifoods-bff.md.
+ private func canTriggerSessionExpiry(for req: ApiRequest) -> Bool {
+ req.requiresAuth && tokenStore.jwt != nil
+ }
+
private func isSessionExpiredEnvelope(_ envelope: ApiEnvelope) -> Bool {
guard envelope.error else { return false }
let code = (envelope.code ?? "").lowercased()
@@ -96,6 +105,16 @@ final class ApiService {
if let birthDate, birthDate.isEmpty == false {
payload["birthDate"] = birthDate
}
+ // If the visitor picked a state/city via the public locator before
+ // signing up, forward it so the backend can set it as the account's
+ // default city. NOTE: as of this writing Atomenta's customer create
+ // controller only reads name/email/phoneNumber — these two fields
+ // are a no-op server-side until that controller is updated to
+ // persist them (see docs/plans/app-migrate-atomenta-calls-to-pedifoods-bff.md).
+ if let state = GuestLocationStore.shared.selectedState, let city = GuestLocationStore.shared.selectedCity {
+ payload["defaultState"] = state
+ payload["defaultCity"] = city
+ }
let body = try JSONEncoder().encode(payload)
let req = ApiRequest(path: "/api/customer", method: "POST", module: .customer, requiresAuth: false, body: body)
return try await sendEnvelope(req)
@@ -308,7 +327,10 @@ final class ApiService {
return cached
}
- let req = ApiRequest(path: "/api/public/categories", method: "GET", module: .none, requiresAuth: false)
+ // Open endpoint, no guest session needed, but it lives on the BFF
+ // domain (pedifoods.com.br), not Atomenta — see
+ // docs/plans/app-migrate-atomenta-calls-to-pedifoods-bff.md.
+ let req = ApiRequest(path: "/api/public/categories", method: "GET", module: .none, requiresAuth: false, baseURLOverride: ApiConfig.pediFoodsBFFURL)
let envelope: ApiEnvelope<[PublicCategory]> = try await sendEnvelope(req)
if envelope.error == false {
AppContentCache.shared.set(envelope, for: publicCategoriesCacheKey, ttl: AppCacheTTL.twoHours)
@@ -447,6 +469,11 @@ final class ApiService {
return try await sendEnvelope(req)
}
+ func deleteAccount() async throws -> ApiEnvelope {
+ let req = ApiRequest(path: "/api/customer/account", method: "DELETE", module: .customer, requiresAuth: true)
+ return try await sendEnvelope(req)
+ }
+
func changePaymentMethod(storeId: String, orderId: String, payload: ChangePaymentMethodPayload) async throws -> ApiEnvelope {
let body = try JSONEncoder().encode(payload)
let req = ApiRequest(path: "/api/store/\(storeId)/orders/\(orderId)/payment-method", method: "PATCH", module: .store, requiresAuth: true, body: body)
diff --git a/Sources/PediFoods/Services/GuestLocationStore.swift b/Sources/PediFoods/Services/GuestLocationStore.swift
new file mode 100644
index 0000000..212af56
--- /dev/null
+++ b/Sources/PediFoods/Services/GuestLocationStore.swift
@@ -0,0 +1,77 @@
+import Foundation
+
+/// Local (device-only) state for the pre-login public store locator —
+/// see docs/plans/app-migrate-atomenta-calls-to-pedifoods-bff.md.
+final class GuestLocationStore: @unchecked Sendable {
+ static let shared = GuestLocationStore()
+
+ private let serviceName = "com.br.pedifoods.app.guest"
+ private let deviceIdKey = "device_id"
+ private let attestationKey = "attestation"
+ private let attestKeyIdKey = "attest_key_id"
+ private let stateKey = "selected_state"
+ private let cityKey = "selected_city"
+
+ /// Stable per-install identifier sent as `deviceId` in the guest handshake.
+ var deviceId: String {
+ if let existing = KeychainStore.load(service: serviceName, key: deviceIdKey) {
+ return existing
+ }
+ let generated = UUID().uuidString
+ KeychainStore.save(generated, service: serviceName, key: deviceIdKey)
+ return generated
+ }
+
+ /// Fallback-only placeholder (backend just checks non-empty) for
+ /// environments that can't run real App Attest — Simulator, or non-iOS.
+ /// Real devices use DCAppAttestService via GuestSessionService instead.
+ var attestationPlaceholder: String {
+ if let existing = KeychainStore.load(service: serviceName, key: attestationKey) {
+ return existing
+ }
+ let generated = UUID().uuidString
+ KeychainStore.save(generated, service: serviceName, key: attestationKey)
+ return generated
+ }
+
+ /// App Attest key ID already registered with the backend for this
+ /// device, if any. Present -> use it to sign assertions; absent -> this
+ /// device needs to attest a freshly generated key first.
+ var appAttestKeyId: String? {
+ get { KeychainStore.load(service: serviceName, key: attestKeyIdKey) }
+ set {
+ if let newValue {
+ KeychainStore.save(newValue, service: serviceName, key: attestKeyIdKey)
+ } else {
+ KeychainStore.delete(service: serviceName, key: attestKeyIdKey)
+ }
+ }
+ }
+
+ var selectedState: String? {
+ get { KeychainStore.load(service: serviceName, key: stateKey) }
+ set {
+ if let newValue {
+ KeychainStore.save(newValue, service: serviceName, key: stateKey)
+ } else {
+ KeychainStore.delete(service: serviceName, key: stateKey)
+ }
+ }
+ }
+
+ var selectedCity: String? {
+ get { KeychainStore.load(service: serviceName, key: cityKey) }
+ set {
+ if let newValue {
+ KeychainStore.save(newValue, service: serviceName, key: cityKey)
+ } else {
+ KeychainStore.delete(service: serviceName, key: cityKey)
+ }
+ }
+ }
+
+ func clearSelectedLocation() {
+ KeychainStore.delete(service: serviceName, key: stateKey)
+ KeychainStore.delete(service: serviceName, key: cityKey)
+ }
+}
diff --git a/Sources/PediFoods/Services/GuestSessionService.swift b/Sources/PediFoods/Services/GuestSessionService.swift
new file mode 100644
index 0000000..869c3ed
--- /dev/null
+++ b/Sources/PediFoods/Services/GuestSessionService.swift
@@ -0,0 +1,222 @@
+import Foundation
+#if os(iOS)
+import DeviceCheck
+import CryptoKit
+#endif
+
+private struct ChallengeResult: Decodable, Sendable {
+ let challenge: String
+}
+
+/// Fields differ by handshake step — Optional properties are omitted from
+/// the encoded JSON entirely (Codable synthesis uses encodeIfPresent), so
+/// this one struct covers both the fresh-attestation and assertion payloads.
+private struct GuestSessionAttestPayload: Encodable, Sendable {
+ let platform: String
+ let deviceId: String
+ let challenge: String
+ let keyId: String?
+ let attestation: String?
+ let assertion: String?
+}
+
+private struct GuestSessionPlaceholderPayload: Encodable, Sendable {
+ let platform: String
+ let deviceId: String
+ let attestation: String
+}
+
+/// Issues and caches the short-lived (15min) guest JWT used by the pre-login
+/// public store locator. Deliberately separate from ApiService/TokenStore —
+/// a guest-session 401 must never be treated as the customer session
+/// expiring (see docs/plans/app-migrate-atomenta-calls-to-pedifoods-bff.md).
+///
+/// iOS uses real App Attest (2-call protocol: challenge, then
+/// attest-a-fresh-key the first time per device or sign-an-assertion with
+/// the already-registered key every time after). If the server doesn't
+/// recognize a previously-registered key (e.g. it lost its in-memory
+/// registration), the assertion call fails and this falls back to
+/// re-attesting with a brand new key rather than leaving the guest stuck.
+actor GuestSessionService {
+ static let shared = GuestSessionService()
+
+ private var cachedToken: String?
+ private var expiresAt: Date?
+ private let client: ApiClient
+ private let store: GuestLocationStore
+
+ // Actors are reentrant across `await` points — without this, two
+ // concurrent callers with no cached token yet (very possible: the
+ // location picker and Home's guest store load can both need a guest
+ // session near launch) would each independently run the App Attest
+ // flow, racing each other's key registration against the server. This
+ // makes every caller share the one in-flight handshake instead.
+ private var inFlightRefresh: Task?
+
+ init(client: ApiClient = ApiClient(), store: GuestLocationStore = .shared) {
+ self.client = client
+ self.store = store
+ }
+
+ func validToken() async throws -> String {
+ if let cachedToken, let expiresAt, expiresAt > Date() {
+ return cachedToken
+ }
+ return try await refreshToken()
+ }
+
+ /// Forces a fresh handshake, used for the silent retry-on-401 flow.
+ func invalidateAndRefresh() async throws -> String {
+ cachedToken = nil
+ expiresAt = nil
+ inFlightRefresh = nil
+ return try await refreshToken()
+ }
+
+ private func refreshToken() async throws -> String {
+ if let inFlightRefresh {
+ return try await inFlightRefresh.value
+ }
+ let task = Task { try await performRefresh() }
+ inFlightRefresh = task
+ defer { inFlightRefresh = nil }
+ return try await task.value
+ }
+
+ private func performRefresh() async throws -> String {
+#if os(iOS)
+ return try await refreshTokenWithAppAttest()
+#else
+ return try await refreshTokenWithPlaceholder()
+#endif
+ }
+
+#if os(iOS)
+ private func refreshTokenWithAppAttest() async throws -> String {
+ guard DCAppAttestService.shared.isSupported else {
+ // Simulator can never support App Attest (hardware limitation,
+ // not environment-specific) — server has its own documented
+ // bypass for this case, gated by an admin toggle server-side.
+ let challenge = try await fetchChallenge()
+ return try await handshakeWithSimulatorBypass(challenge: challenge)
+ }
+
+ if let existingKeyId = store.appAttestKeyId {
+ do {
+ let challenge = try await fetchChallenge()
+ return try await handshakeWithAssertion(keyId: existingKeyId, challenge: challenge)
+ } catch let error as NetworkError where isKeyRejectedByServer(error) {
+ // Server explicitly rejected this key (403
+ // APP_ATTEST_VERIFICATION_FAILED — e.g. it lost the
+ // credential registration) — re-attest with a new key. Any
+ // other error (network blip, timeout, decode issue) must
+ // NOT wipe a perfectly valid registered key.
+ store.appAttestKeyId = nil
+ }
+ }
+
+ let challenge = try await fetchChallenge()
+ return try await handshakeWithFreshAttestation(challenge: challenge)
+ }
+
+ private func isKeyRejectedByServer(_ error: NetworkError) -> Bool {
+ if case .httpError(403, _) = error { return true }
+ return false
+ }
+
+ private func fetchChallenge() async throws -> String {
+ let req = ApiRequest(
+ path: "/api/public/attest/challenge",
+ method: "GET",
+ module: .none,
+ requiresAuth: false,
+ queryItems: [URLQueryItem(name: "deviceId", value: store.deviceId)],
+ baseURLOverride: ApiConfig.pediFoodsBFFURL
+ )
+ let envelope: ApiEnvelope = try await client.send(req)
+ guard envelope.error == false, let result = envelope.result else {
+ throw NetworkError.invalidResponse
+ }
+ return result.challenge
+ }
+
+ private func handshakeWithFreshAttestation(challenge: String) async throws -> String {
+ let keyId = try await DCAppAttestService.shared.generateKey()
+ let clientDataHash = Data(SHA256.hash(data: Data(challenge.utf8)))
+ let attestationObject = try await DCAppAttestService.shared.attestKey(keyId, clientDataHash: clientDataHash)
+
+ let payload = GuestSessionAttestPayload(
+ platform: "ios",
+ deviceId: store.deviceId,
+ challenge: challenge,
+ keyId: keyId,
+ attestation: attestationObject.base64EncodedString(),
+ assertion: nil
+ )
+ let token = try await sendSessionRequest(body: JSONEncoder().encode(payload))
+ store.appAttestKeyId = keyId
+ return token
+ }
+
+ /// Simulator can never run real App Attest — server accepts this literal
+ /// bypass value instead, gated by its own admin toggle (403
+ /// SIMULATOR_BYPASS_DISABLED if that toggle is off; not a client bug).
+ private func handshakeWithSimulatorBypass(challenge: String) async throws -> String {
+ let payload = GuestSessionAttestPayload(
+ platform: "ios",
+ deviceId: store.deviceId,
+ challenge: challenge,
+ keyId: nil,
+ attestation: "SIMULATOR_BYPASS",
+ assertion: nil
+ )
+ return try await sendSessionRequest(body: JSONEncoder().encode(payload))
+ }
+
+ private func handshakeWithAssertion(keyId: String, challenge: String) async throws -> String {
+ let clientDataHash = Data(SHA256.hash(data: Data(challenge.utf8)))
+ let assertionObject = try await DCAppAttestService.shared.generateAssertion(keyId, clientDataHash: clientDataHash)
+
+ let payload = GuestSessionAttestPayload(
+ platform: "ios",
+ deviceId: store.deviceId,
+ challenge: challenge,
+ keyId: nil,
+ attestation: nil,
+ assertion: assertionObject.base64EncodedString()
+ )
+ return try await sendSessionRequest(body: JSONEncoder().encode(payload))
+ }
+#endif
+
+ /// Only reachable on non-iOS builds (this app's macOS target is test-only,
+ /// never a real distribution target — no documented contract for it, so
+ /// this stays a best-effort placeholder rather than matching a real spec.
+ private func refreshTokenWithPlaceholder() async throws -> String {
+ let payload = GuestSessionPlaceholderPayload(
+ platform: "ios",
+ deviceId: store.deviceId,
+ attestation: store.attestationPlaceholder
+ )
+ return try await sendSessionRequest(body: JSONEncoder().encode(payload))
+ }
+
+ private func sendSessionRequest(body: Data) async throws -> String {
+ let req = ApiRequest(
+ path: "/api/public/session",
+ method: "POST",
+ module: .none,
+ requiresAuth: false,
+ body: body,
+ baseURLOverride: ApiConfig.pediFoodsBFFURL
+ )
+ let envelope: ApiEnvelope = try await client.send(req)
+ guard envelope.error == false, let result = envelope.result else {
+ throw NetworkError.invalidResponse
+ }
+ cachedToken = result.guestToken
+ // Refresh a bit early so a request started near expiry doesn't race the server's own clock.
+ expiresAt = Date().addingTimeInterval(TimeInterval(result.expiresIn) - 30)
+ return result.guestToken
+ }
+}
diff --git a/Sources/PediFoods/Services/KeychainStore.swift b/Sources/PediFoods/Services/KeychainStore.swift
new file mode 100644
index 0000000..fcb48a7
--- /dev/null
+++ b/Sources/PediFoods/Services/KeychainStore.swift
@@ -0,0 +1,60 @@
+import Foundation
+#if os(iOS)
+import Security
+#endif
+
+/// Small generic Keychain wrapper (iOS) with a UserDefaults fallback on other
+/// platforms (macOS test target), namespaced by `service`+`key`.
+enum KeychainStore {
+ static func save(_ value: String, service: String, key: String) {
+#if os(iOS)
+ guard let data = value.data(using: .utf8) else { return }
+ let query: [String: Any] = [
+ kSecClass as String: kSecClassGenericPassword,
+ kSecAttrService as String: service,
+ kSecAttrAccount as String: key
+ ]
+ SecItemDelete(query as CFDictionary)
+ var attributes = query
+ attributes[kSecValueData as String] = data
+ SecItemAdd(attributes as CFDictionary, nil)
+#else
+ UserDefaults.standard.set(value, forKey: "\(service).\(key)")
+#endif
+ }
+
+ static func load(service: String, key: String) -> String? {
+#if os(iOS)
+ let query: [String: Any] = [
+ kSecClass as String: kSecClassGenericPassword,
+ kSecAttrService as String: service,
+ kSecAttrAccount as String: key,
+ kSecReturnData as String: true,
+ kSecMatchLimit as String: kSecMatchLimitOne
+ ]
+ var result: AnyObject?
+ let status = SecItemCopyMatching(query as CFDictionary, &result)
+ guard status == errSecSuccess,
+ let data = result as? Data,
+ let value = String(data: data, encoding: .utf8) else {
+ return nil
+ }
+ return value
+#else
+ UserDefaults.standard.string(forKey: "\(service).\(key)")
+#endif
+ }
+
+ static func delete(service: String, key: String) {
+#if os(iOS)
+ let query: [String: Any] = [
+ kSecClass as String: kSecClassGenericPassword,
+ kSecAttrService as String: service,
+ kSecAttrAccount as String: key
+ ]
+ SecItemDelete(query as CFDictionary)
+#else
+ UserDefaults.standard.removeObject(forKey: "\(service).\(key)")
+#endif
+ }
+}
diff --git a/Sources/PediFoods/Services/PublicLocationModels.swift b/Sources/PediFoods/Services/PublicLocationModels.swift
new file mode 100644
index 0000000..516c2a2
--- /dev/null
+++ b/Sources/PediFoods/Services/PublicLocationModels.swift
@@ -0,0 +1,117 @@
+import Foundation
+
+// DTOs for the pre-login public store locator (pedifoods.com.br BFF).
+// See docs/plans/app-migrate-atomenta-calls-to-pedifoods-bff.md for the
+// exact contract these mirror.
+
+struct GuestSessionResult: Decodable, Sendable {
+ let guestToken: String
+ let expiresIn: Int
+}
+
+/// `GET /api/public/locations` returns states+cities in one call, keyed by
+/// state UF with an array of city names — e.g. `{"SP": ["Aguaí", "Campinas"]}`.
+typealias PublicLocationsResult = [String: [String]]
+
+struct PublicStoreListItem: Decodable, Sendable, Identifiable {
+ let id: String
+ let storeId: String?
+ let name: String?
+ let logo: String?
+ let cover: String?
+ let category: String?
+ let isOpen: Bool?
+ let statusLabel: String?
+ let nextOpenLabel: String?
+ let rating: Double?
+ let totalReviews: Int?
+ let deliveryTime: String?
+ let deliveryFee: Double?
+ let minOrder: Double?
+}
+
+struct PublicStoreDetail: Decodable, Sendable {
+ let id: String
+ let storeId: String?
+ let slug: String?
+ let fantasyName: String?
+ let razaoSocial: String?
+ let logo: String?
+ let cover: String?
+ let specialty: String?
+ let phone: String?
+ let responsiblePhone: String?
+ let address: String?
+ let neighborhood: String?
+ let city: String?
+ let state: String?
+ let zipcode: String?
+ let isOpen: Bool?
+ let statusLabel: String?
+ let nextOpenLabel: String?
+ let averageRate: Double?
+ let totalReviews: Int?
+ let deliveryTime: String?
+ let deliveryFee: Double?
+ let deliveryPrice: Double?
+ let minOrder: Double?
+ let acceptPix: Bool?
+}
+
+// Maps the public (anonymous) store-detail projection onto the same models
+// StoreDetailView already renders for authenticated users, so the view
+// itself doesn't need to know which source the data came from.
+extension StoreInfoResult {
+ init(publicDetail: PublicStoreDetail) {
+ isOpen = publicDetail.isOpen
+ statusLabel = publicDetail.statusLabel
+ fantasyName = publicDetail.fantasyName
+ phone = publicDetail.phone
+ whatsapp = nil
+ logo = publicDetail.logo
+ cover = publicDetail.cover
+ deliveryTime = publicDetail.deliveryTime
+ minOrder = publicDetail.minOrder
+ address = StoreAddressInfo(publicDetail: publicDetail)
+ paymentMethods = StorePaymentMethodsInfo(acceptPix: publicDetail.acceptPix)
+ }
+}
+
+extension StoreAddressInfo {
+ init(publicDetail: PublicStoreDetail) {
+ street = publicDetail.address
+ number = nil
+ neighborhood = publicDetail.neighborhood
+ city = publicDetail.city
+ state = publicDetail.state
+ zipCode = publicDetail.zipcode
+ latitude = nil
+ longitude = nil
+ }
+}
+
+extension StorePaymentMethodsInfo {
+ /// The public projection only exposes whether Pix is accepted — every
+ /// other payment flag is unknown until the user is authenticated and can
+ /// see it via the real store-info call.
+ init(acceptPix: Bool?) {
+ paymentOnDelivery = nil
+ paymentOnPickup = nil
+ self.acceptPix = acceptPix
+ acceptCash = nil
+ acceptCreditCard = nil
+ acceptDebitCard = nil
+ acceptCreditVisa = nil
+ acceptCreditMaster = nil
+ acceptCreditElo = nil
+ acceptCreditAmex = nil
+ acceptCreditHipercard = nil
+ acceptDebitVisa = nil
+ acceptDebitMaster = nil
+ acceptDebitElo = nil
+ acceptVoucherAlelo = nil
+ acceptVoucherSodexo = nil
+ acceptVoucherTicket = nil
+ acceptVoucherVR = nil
+ }
+}
diff --git a/Sources/PediFoods/Services/PublicLocationService.swift b/Sources/PediFoods/Services/PublicLocationService.swift
new file mode 100644
index 0000000..af0eade
--- /dev/null
+++ b/Sources/PediFoods/Services/PublicLocationService.swift
@@ -0,0 +1,110 @@
+import Foundation
+
+/// Pre-login public store locator — states/cities/stores-by-location, and
+/// store detail. Always talks to `pedifoods.com.br` (the BFF), never
+/// `atomenta.com.br` directly. See
+/// docs/plans/app-migrate-atomenta-calls-to-pedifoods-bff.md.
+///
+/// Deliberately does not go through ApiService.sendEnvelope — that treats
+/// any session-expired-shaped response as the *customer* session expiring
+/// (clears TokenStore, forces logout). A guest-session 401 here means the
+/// short-lived guest JWT expired and must be silently refreshed instead.
+final class PublicLocationService: @unchecked Sendable {
+ static let shared = PublicLocationService()
+
+ private let client: ApiClient
+ private let guestSession: GuestSessionService
+
+ init(client: ApiClient = ApiClient(), guestSession: GuestSessionService = .shared) {
+ self.client = client
+ self.guestSession = guestSession
+ }
+
+ /// States + their cities in one call — keyed by UF, e.g. `{"SP": [...]}`.
+ func fetchLocations() async throws -> PublicLocationsResult {
+ try await sendGuestAuthed(path: "/api/public/locations")
+ }
+
+ func fetchStores(state: String, city: String) async throws -> [PublicStoreListItem] {
+ let query = [
+ URLQueryItem(name: "state", value: state),
+ URLQueryItem(name: "city", value: city)
+ ]
+ return try await sendGuestAuthed(path: "/api/public/stores/by-location", query: query)
+ }
+
+ /// No guest token — this route is fully public/unauthenticated per the doc.
+ func fetchStoreDetail(identifier: String) async throws -> PublicStoreDetail {
+ let encodedIdentifier = identifier.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? identifier
+ let req = ApiRequest(
+ path: "/api/public/store/\(encodedIdentifier)",
+ method: "GET",
+ module: .none,
+ requiresAuth: false,
+ baseURLOverride: ApiConfig.pediFoodsBFFURL
+ )
+ let envelope: ApiEnvelope = try await client.send(req)
+ guard envelope.error == false, let result = envelope.result else {
+ throw NetworkError.invalidResponse
+ }
+ return result
+ }
+
+ /// No guest token — same open pattern as fetchStoreDetail. Reuses the
+ /// existing StoreCatalogCategory/StoreCatalogProduct models directly:
+ /// the public response is the same category+products shape (with extra
+ /// computed inventory/stockStatus fields the decoder just ignores).
+ func fetchStoreProducts(storeId: String) async throws -> [StoreCatalogCategory] {
+ let encodedStoreId = storeId.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? storeId
+ let req = ApiRequest(
+ path: "/api/public/store/\(encodedStoreId)/products",
+ method: "GET",
+ module: .none,
+ requiresAuth: false,
+ baseURLOverride: ApiConfig.pediFoodsBFFURL
+ )
+ let envelope: ApiEnvelope<[StoreCatalogCategory]> = try await client.send(req)
+ guard envelope.error == false, let result = envelope.result else {
+ throw NetworkError.invalidResponse
+ }
+ return result
+ }
+
+ private func sendGuestAuthed(path: String, query: [URLQueryItem] = []) async throws -> T {
+ let token = try await guestSession.validToken()
+ do {
+ return try await performGuestRequest(path: path, query: query, token: token)
+ } catch let error as NetworkError where isGuestSessionExpired(error) {
+ let refreshed = try await guestSession.invalidateAndRefresh()
+ return try await performGuestRequest(path: path, query: query, token: refreshed)
+ }
+ }
+
+ private func performGuestRequest(path: String, query: [URLQueryItem], token: String) async throws -> T {
+ let req = ApiRequest(
+ path: path,
+ method: "GET",
+ module: .none,
+ requiresAuth: false,
+ queryItems: query,
+ baseURLOverride: ApiConfig.pediFoodsBFFURL,
+ customBearerToken: token
+ )
+ let envelope: ApiEnvelope = try await client.send(req)
+ guard envelope.error == false, let result = envelope.result else {
+ throw NetworkError.invalidResponse
+ }
+ return result
+ }
+
+ private func isGuestSessionExpired(_ error: NetworkError) -> Bool {
+ switch error {
+ case .unauthorized:
+ return true
+ case .httpError(let statusCode, _):
+ return statusCode == 401
+ default:
+ return false
+ }
+ }
+}
diff --git a/Sources/PediFoods/Services/TokenStore.swift b/Sources/PediFoods/Services/TokenStore.swift
index 7813a31..a0155c8 100644
--- a/Sources/PediFoods/Services/TokenStore.swift
+++ b/Sources/PediFoods/Services/TokenStore.swift
@@ -1,7 +1,4 @@
import Foundation
-#if os(iOS)
-import Security
-#endif
protocol TokenStore: AnyObject {
var jwt: String? { get set }
@@ -10,12 +7,13 @@ protocol TokenStore: AnyObject {
final class DefaultTokenStore: TokenStore {
private let key = "auth_jwt"
+ private let serviceName = "com.br.pedifoods.app.auth"
private let defaults = UserDefaults.standard
var jwt: String? {
get {
#if os(iOS)
- if let keychainValue = loadKeychainValue(for: key) {
+ if let keychainValue = KeychainStore.load(service: serviceName, key: key) {
return keychainValue
}
#endif
@@ -24,9 +22,9 @@ final class DefaultTokenStore: TokenStore {
set {
#if os(iOS)
if let newValue {
- saveKeychainValue(newValue, for: key)
+ KeychainStore.save(newValue, service: serviceName, key: key)
} else {
- deleteKeychainValue(for: key)
+ KeychainStore.delete(service: serviceName, key: key)
}
#endif
defaults.set(newValue, forKey: key)
@@ -35,57 +33,8 @@ final class DefaultTokenStore: TokenStore {
func clear() {
#if os(iOS)
- deleteKeychainValue(for: key)
+ KeychainStore.delete(service: serviceName, key: key)
#endif
defaults.removeObject(forKey: key)
}
-
-#if os(iOS)
- private var serviceName: String { "com.br.pedifoods.app.auth" }
-
- private func saveKeychainValue(_ value: String, for key: String) {
- guard let data = value.data(using: .utf8) else { return }
- let query: [String: Any] = [
- kSecClass as String: kSecClassGenericPassword,
- kSecAttrService as String: serviceName,
- kSecAttrAccount as String: key
- ]
-
- SecItemDelete(query as CFDictionary)
- let attributes: [String: Any] = [
- kSecClass as String: kSecClassGenericPassword,
- kSecAttrService as String: serviceName,
- kSecAttrAccount as String: key,
- kSecValueData as String: data
- ]
- SecItemAdd(attributes as CFDictionary, nil)
- }
-
- private func loadKeychainValue(for key: String) -> String? {
- let query: [String: Any] = [
- kSecClass as String: kSecClassGenericPassword,
- kSecAttrService as String: serviceName,
- kSecAttrAccount as String: key,
- kSecReturnData as String: true,
- kSecMatchLimit as String: kSecMatchLimitOne
- ]
- var result: AnyObject?
- let status = SecItemCopyMatching(query as CFDictionary, &result)
- guard status == errSecSuccess,
- let data = result as? Data,
- let value = String(data: data, encoding: .utf8) else {
- return nil
- }
- return value
- }
-
- private func deleteKeychainValue(for key: String) {
- let query: [String: Any] = [
- kSecClass as String: kSecClassGenericPassword,
- kSecAttrService as String: serviceName,
- kSecAttrAccount as String: key
- ]
- SecItemDelete(query as CFDictionary)
- }
-#endif
}
diff --git a/Sources/PediFoods/Views/Main/CartView.swift b/Sources/PediFoods/Views/Main/CartView.swift
index 00e93aa..9f64b4b 100644
--- a/Sources/PediFoods/Views/Main/CartView.swift
+++ b/Sources/PediFoods/Views/Main/CartView.swift
@@ -4,6 +4,7 @@ import SwiftUI
struct CartView: View {
@Binding var appState: AppState
@Binding var selectedTab: MainTab
+ @Binding var root: RootFlow
@State var openCheckout = false
@State var couponCode = ""
@State var appliedCouponCode: String? = nil
@@ -148,6 +149,10 @@ struct CartView: View {
summaryRow(title: "Total", value: formatCurrency(totalValue), highlighted: true)
Button {
+ guard appState.session.isAuthenticated else {
+ root = .auth
+ return
+ }
openCheckout = true
} label: {
HStack(spacing: 10) {
diff --git a/Sources/PediFoods/Views/Main/HomeView+Data.swift b/Sources/PediFoods/Views/Main/HomeView+Data.swift
index 355fa8b..2c1510b 100644
--- a/Sources/PediFoods/Views/Main/HomeView+Data.swift
+++ b/Sources/PediFoods/Views/Main/HomeView+Data.swift
@@ -135,4 +135,62 @@ extension HomeView {
}
return "Não foi possível carregar os estabelecimentos."
}
+
+ /// Anonymous store loading: no account, no coordinates — just the
+ /// manually-picked state/city from the public locator. The BFF endpoint
+ /// has no category filter, so any category chip selection is applied
+ /// client-side via `filteredStores` (HomeView+Filtering.swift), same as
+ /// the multi-select filters already do.
+ @MainActor
+ func loadGuestStores(hadExistingStores: Bool, category: String?, refreshCategories: Bool) async {
+ guard let state = GuestLocationStore.shared.selectedState,
+ let city = GuestLocationStore.shared.selectedCity else {
+ isLoadingStores = false
+ stores = []
+ storesError = "Escolha um estado e cidade para visualizar os estabelecimentos."
+ appState.activeModal = .addressPicker
+ return
+ }
+
+ do {
+ let items = try await PublicLocationService.shared.fetchStores(state: state, city: city)
+ isLoadingStores = false
+ let mapped = items.map(StoreSummary.init(publicItem:))
+ stores = mapped
+ if refreshCategories || (category == nil && categories.count <= 1) {
+ await loadHomeCategories(withFallbackStores: mapped, forceRefresh: refreshCategories)
+ if categories.contains(where: { $0.id == selectedCategory }) == false {
+ selectedCategory = "all"
+ }
+ }
+ storesError = nil
+ } catch {
+ if isCancelledRequest(error) {
+ isLoadingStores = false
+ return
+ }
+ isLoadingStores = false
+ reportStoresLoadFailure(storesUserMessage(error), hadExistingStores: hadExistingStores)
+ }
+ }
+}
+
+extension StoreSummary {
+ /// Maps the public-locator DTO onto the same model HomeView already
+ /// renders — distance/positiveReviews don't exist in that response.
+ init(publicItem: PublicStoreListItem) {
+ self.id = publicItem.id
+ self.name = publicItem.name ?? "Loja"
+ self.logo = publicItem.logo
+ self.cover = publicItem.cover
+ self.category = publicItem.category
+ self.rating = publicItem.rating
+ self.reviewsCount = publicItem.totalReviews
+ self.positiveReviews = nil
+ self.deliveryTime = publicItem.deliveryTime
+ self.deliveryFee = publicItem.deliveryFee
+ self.distance = nil
+ self.isOpen = publicItem.isOpen
+ self.statusLabel = publicItem.statusLabel
+ }
}
diff --git a/Sources/PediFoods/Views/Main/HomeView.swift b/Sources/PediFoods/Views/Main/HomeView.swift
index 7f4a23c..093e102 100644
--- a/Sources/PediFoods/Views/Main/HomeView.swift
+++ b/Sources/PediFoods/Views/Main/HomeView.swift
@@ -123,17 +123,7 @@ struct HomeView: View {
HStack(spacing: 16) {
ForEach(featuredStoresCards) { store in
NavigationLink {
- StoreDetailView(
- storeId: store.id,
- storeName: store.name,
- storeCoverURL: store.coverURL,
- storeLogoURL: store.logoURL,
- storeCategory: store.category,
- storeRating: store.rating,
- storeDistance: store.distance,
- storeDeliveryFee: store.deliveryFee,
- appState: $appState
- )
+ storeDestination(for: store)
} label: {
FeaturedStoreCard(
store: store,
@@ -210,22 +200,27 @@ struct HomeView: View {
}
}
+ @ViewBuilder
+ private func storeDestination(for store: FeaturedStoreCardModel) -> some View {
+ StoreDetailView(
+ storeId: store.id,
+ storeName: store.name,
+ storeCoverURL: store.coverURL,
+ storeLogoURL: store.logoURL,
+ storeCategory: store.category,
+ storeRating: store.rating,
+ storeDistance: store.distance,
+ storeDeliveryFee: store.deliveryFee,
+ appState: $appState
+ )
+ }
+
@ViewBuilder
private var storeCardsList: some View {
VStack(spacing: 16) {
ForEach(filteredStoreCards) { store in
NavigationLink {
- StoreDetailView(
- storeId: store.id,
- storeName: store.name,
- storeCoverURL: store.coverURL,
- storeLogoURL: store.logoURL,
- storeCategory: store.category,
- storeRating: store.rating,
- storeDistance: store.distance,
- storeDeliveryFee: store.deliveryFee,
- appState: $appState
- )
+ storeDestination(for: store)
} label: {
FeaturedStoreCard(
store: store,
@@ -407,6 +402,14 @@ struct HomeView: View {
storesError = nil
}
+ // Anonymous browsing has no account address/coordinates — the public
+ // locator uses a manually-picked state/city instead (geolocation is
+ // out of scope for that flow, see public-store-locator-sdd.md).
+ guard appState.session.isAuthenticated else {
+ await loadGuestStores(hadExistingStores: hadExistingStores, category: category, refreshCategories: refreshCategories)
+ return
+ }
+
let coordinate = await resolveCoordinates(forceRefresh: forceLocationRefresh)
let hasAddress = hasConfiguredAddress()
@@ -498,7 +501,7 @@ struct HomeView: View {
}
}
- private func reportStoresLoadFailure(_ message: String, hadExistingStores: Bool) {
+ func reportStoresLoadFailure(_ message: String, hadExistingStores: Bool) {
if hadExistingStores {
SnackbarCenter.shared.show(title: message, style: .warning, icon: "exclamationmark.triangle.fill", duration: 3.0)
} else {
@@ -548,7 +551,7 @@ struct HomeView: View {
return selected.title
}
- private func isCancelledRequest(_ error: Error) -> Bool {
+ func isCancelledRequest(_ error: Error) -> Bool {
if error is CancellationError {
return true
}
diff --git a/Sources/PediFoods/Views/Main/HomeViewComponents.swift b/Sources/PediFoods/Views/Main/HomeViewComponents.swift
index 2a519f3..b94b3c9 100644
--- a/Sources/PediFoods/Views/Main/HomeViewComponents.swift
+++ b/Sources/PediFoods/Views/Main/HomeViewComponents.swift
@@ -44,6 +44,8 @@ struct SearchBar: View {
.foregroundStyle(AppColors.textMuted)
TextField(placeholder, text: $text)
.appNoAutoCap()
+ .foregroundStyle(AppColors.textPrimary)
+ .tint(AppColors.textPrimary)
Spacer()
Button(action: onFilterTap) {
Image(systemName: "slider.horizontal.3")
diff --git a/Sources/PediFoods/Views/Main/MainTabView.swift b/Sources/PediFoods/Views/Main/MainTabView.swift
index 3fffbf8..d9eec5f 100644
--- a/Sources/PediFoods/Views/Main/MainTabView.swift
+++ b/Sources/PediFoods/Views/Main/MainTabView.swift
@@ -16,11 +16,15 @@ struct MainTabView: View {
}
case .cart:
NavigationStack {
- CartView(appState: $appState, selectedTab: $selectedTab)
+ CartView(appState: $appState, selectedTab: $selectedTab, root: $root)
}
case .profile:
NavigationStack {
- ProfileView(root: $root, selectedTab: $selectedTab, tokenStore: tokenStore, appState: $appState)
+ if appState.session.isAuthenticated {
+ ProfileView(root: $root, selectedTab: $selectedTab, tokenStore: tokenStore, appState: $appState)
+ } else {
+ ProfileLoggedOutView(root: $root)
+ }
}
}
}
diff --git a/Sources/PediFoods/Views/Main/ProfileView.swift b/Sources/PediFoods/Views/Main/ProfileView.swift
index 0064efb..7666ed3 100644
--- a/Sources/PediFoods/Views/Main/ProfileView.swift
+++ b/Sources/PediFoods/Views/Main/ProfileView.swift
@@ -14,6 +14,8 @@ struct ProfileView: View {
@State var openAddressesOnboarding = false
@State var onboardingMessage: String? = nil
@State var showLogoutAlert = false
+ @State var showDeleteAccountAlert = false
+ @State var isDeletingAccount = false
@State private var openOrders = false
let tabBarClearance: CGFloat = 120
@@ -87,6 +89,25 @@ struct ProfileView: View {
.padding(.top, 10)
.padding(.horizontal, 20)
+ Button(action: { showDeleteAccountAlert = true }) {
+ HStack(spacing: 10) {
+ if isDeletingAccount {
+ ProgressView()
+ .tint(Color.red)
+ } else {
+ Image(systemName: "trash.fill")
+ .font(.system(size: 16, weight: .semibold))
+ }
+ Text("Excluir Conta")
+ .font(AppTypography.body)
+ }
+ .foregroundStyle(Color.red.opacity(0.7))
+ }
+ .buttonStyle(.plain)
+ .disabled(isDeletingAccount)
+ .padding(.top, 2)
+ .padding(.horizontal, 20)
+
Text("Versão 1.0b")
.font(.system(size: 16, weight: .medium))
.foregroundStyle(AppColors.textMuted)
@@ -130,6 +151,14 @@ struct ProfileView: View {
} message: {
Text("Tem certeza que deseja sair da sua conta?")
}
+ .alert("Excluir sua conta?", isPresented: $showDeleteAccountAlert) {
+ Button("Cancelar", role: .cancel) {}
+ Button("Excluir", role: .destructive) {
+ Task { await deleteAccount() }
+ }
+ } message: {
+ Text("Essa ação é permanente. Seus dados de perfil, endereços, cartões e favoritos serão excluídos e não poderão ser recuperados.")
+ }
.onAppear {
guard let message = appState.address.onboardingMessage else {
return
@@ -251,6 +280,78 @@ struct ProfileView: View {
appState = AppState()
root = .auth
}
+
+ @MainActor
+ private func deleteAccount() async {
+ guard isDeletingAccount == false else { return }
+ isDeletingAccount = true
+ defer { isDeletingAccount = false }
+
+ do {
+ let response = try await ApiService().deleteAccount()
+ guard response.error == false else {
+ SnackbarCenter.shared.show(
+ title: response.message ?? "Não foi possível excluir sua conta.",
+ style: .error,
+ icon: "xmark.octagon.fill",
+ duration: 3.5
+ )
+ return
+ }
+ logout()
+ } catch {
+ SnackbarCenter.shared.show(
+ title: "Não foi possível excluir sua conta. Tente novamente.",
+ style: .error,
+ icon: "xmark.octagon.fill",
+ duration: 3.5
+ )
+ }
+ }
+}
+
+struct ProfileLoggedOutView: View {
+ @Binding var root: RootFlow
+
+ var body: some View {
+ VStack(spacing: 18) {
+ Spacer()
+
+ Image(systemName: "person.crop.circle.badge.questionmark")
+ .font(.system(size: 56, weight: .regular))
+ .foregroundStyle(AppColors.textMuted)
+
+ Text("Entre na sua conta")
+ .font(AppTypography.heading2)
+ .foregroundStyle(AppColors.textPrimary)
+
+ Text("Faça login ou cadastre-se para ver seu perfil, pedidos e endereços.")
+ .font(AppTypography.body)
+ .foregroundStyle(AppColors.textMuted)
+ .multilineTextAlignment(.center)
+ .padding(.horizontal, 32)
+
+ Button {
+ root = .auth
+ } label: {
+ Text("Entrar ou Cadastrar")
+ .font(AppTypography.heading3)
+ .foregroundStyle(AppColors.textInverse)
+ .frame(maxWidth: .infinity)
+ .padding(.vertical, 14)
+ .background(AppColors.primary)
+ .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
+ }
+ .buttonStyle(.plain)
+ .padding(.horizontal, 32)
+ .padding(.top, 8)
+
+ Spacer()
+ Spacer()
+ }
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ .background(AppColors.backgroundLight)
+ }
}
struct ProfileMenuRow: View {
diff --git a/Sources/PediFoods/Views/Main/PublicLocationPickerView.swift b/Sources/PediFoods/Views/Main/PublicLocationPickerView.swift
new file mode 100644
index 0000000..085b911
--- /dev/null
+++ b/Sources/PediFoods/Views/Main/PublicLocationPickerView.swift
@@ -0,0 +1,174 @@
+import SwiftUI
+
+/// State -> city picker for anonymous browsing (public store locator).
+/// Replaces AddressesView in the address-picker modal when the user is
+/// not authenticated — see docs/plans/public-store-locator-sdd.md.
+struct PublicLocationPickerView: View {
+ @Binding var appState: AppState
+ @Environment(\.dismiss) var dismiss
+
+ private enum Step {
+ case state
+ case city
+ }
+
+ @State private var step: Step = .state
+ @State private var locations: PublicLocationsResult = [:]
+ @State private var selectedState: String? = nil
+ @State private var isLoading = false
+ @State private var errorMessage: String? = nil
+
+ private var states: [String] {
+ locations.keys.sorted()
+ }
+
+ private var cities: [String] {
+ guard let selectedState else { return [] }
+ return (locations[selectedState] ?? []).sorted()
+ }
+
+ var body: some View {
+ ZStack {
+ AppColors.backgroundLight.ignoresSafeArea()
+
+ VStack(spacing: 20) {
+ header
+
+ if isLoading {
+ ProgressView()
+ .padding(.top, 40)
+ } else if let errorMessage {
+ VStack(spacing: 12) {
+ Text(errorMessage)
+ .font(AppTypography.body)
+ .foregroundStyle(AppColors.textMuted)
+ .multilineTextAlignment(.center)
+ Button("Tentar novamente") {
+ Task { await loadLocations() }
+ }
+ .font(AppTypography.heading3)
+ .foregroundStyle(AppColors.primary)
+ }
+ .padding(.horizontal, 20)
+ .padding(.top, 40)
+ } else {
+ list
+ }
+
+ Spacer(minLength: 0)
+ }
+ .padding(.top, 18)
+ }
+ .navigationBarBackButtonHidden(true)
+ .appHiddenNavigationBar()
+ .task {
+ await loadLocations()
+ }
+ }
+
+ private var header: some View {
+ ZStack {
+ Text(step == .state ? "Escolha seu estado" : "Escolha sua cidade")
+ .font(AppTypography.heading2)
+ .foregroundStyle(AppColors.textPrimary)
+
+ HStack {
+ Button(action: back) {
+ Image(systemName: "chevron.left")
+ .font(.system(size: 24, weight: .semibold))
+ .foregroundStyle(AppColors.textPrimary)
+ .frame(width: 52, height: 52)
+ .background(AppColors.surface)
+ .clipShape(Circle())
+ .shadow(color: .black.opacity(0.06), radius: 8, y: 2)
+ }
+ .buttonStyle(.plain)
+ Spacer()
+ }
+ }
+ .padding(.horizontal, 20)
+ }
+
+ private var list: some View {
+ ScrollView(showsIndicators: false) {
+ LazyVStack(spacing: 12) {
+ switch step {
+ case .state:
+ ForEach(states, id: \.self) { state in
+ rowButton(title: state) {
+ selectedState = state
+ step = .city
+ }
+ }
+ case .city:
+ ForEach(cities, id: \.self) { city in
+ rowButton(title: city) {
+ confirmSelection(city: city)
+ }
+ }
+ }
+ }
+ .padding(.horizontal, 20)
+ .padding(.top, 8)
+ }
+ }
+
+ private func rowButton(title: String, action: @escaping () -> Void) -> some View {
+ Button(action: action) {
+ HStack {
+ Text(title)
+ .font(AppTypography.body)
+ .foregroundStyle(AppColors.textPrimary)
+ Spacer()
+ Image(systemName: "chevron.right")
+ .font(.system(size: 14, weight: .semibold))
+ .foregroundStyle(AppColors.textMuted)
+ }
+ .padding(.horizontal, 18)
+ .padding(.vertical, 16)
+ .background(AppColors.surface)
+ .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
+ }
+ .buttonStyle(.plain)
+ }
+
+ private func back() {
+ switch step {
+ case .city:
+ step = .state
+ errorMessage = nil
+ case .state:
+ dismiss()
+ }
+ }
+
+ private func confirmSelection(city: String) {
+ guard let selectedState else { return }
+ GuestLocationStore.shared.selectedState = selectedState
+ GuestLocationStore.shared.selectedCity = city
+ appState.address.display = "\(city), \(selectedState)"
+ appState.address.onboardingMessage = nil
+ dismiss()
+ }
+
+ @MainActor
+ private func loadLocations() async {
+ isLoading = true
+ errorMessage = nil
+ do {
+ locations = try await PublicLocationService.shared.fetchLocations()
+ if locations.isEmpty {
+ errorMessage = "Nenhum estado disponível no momento."
+ }
+ } catch {
+ errorMessage = "Não foi possível carregar. Tente novamente."
+ }
+ isLoading = false
+ }
+}
+
+#Preview {
+ NavigationStack {
+ PublicLocationPickerView(appState: .constant(AppState()))
+ }
+}
diff --git a/Sources/PediFoods/Views/Main/StoreDetailView+Logic.swift b/Sources/PediFoods/Views/Main/StoreDetailView+Logic.swift
index cd0f82b..a45bedb 100644
--- a/Sources/PediFoods/Views/Main/StoreDetailView+Logic.swift
+++ b/Sources/PediFoods/Views/Main/StoreDetailView+Logic.swift
@@ -167,36 +167,59 @@ extension StoreDetailView {
}
do {
- let apiService = ApiService()
- let infoResponse = try await apiService.storeInfo(storeId: storeId)
- let catalogResponse = try await apiService.storeCatalog(storeId: storeId)
+ if appState.session.isAuthenticated {
+ let apiService = ApiService()
+ let infoResponse = try await apiService.storeInfo(storeId: storeId)
+ let catalogResponse = try await apiService.storeCatalog(storeId: storeId)
- if infoResponse.error {
- isLoading = false
- reportStoreLoadFailure(infoResponse.message ?? "Não foi possível carregar a loja.", hadExistingContent: hadExistingContent)
- return
- }
- if catalogResponse.error {
- isLoading = false
- reportStoreLoadFailure(catalogResponse.message ?? "Não foi possível carregar o catálogo.", hadExistingContent: hadExistingContent)
- return
- }
+ if infoResponse.error {
+ isLoading = false
+ reportStoreLoadFailure(infoResponse.message ?? "Não foi possível carregar a loja.", hadExistingContent: hadExistingContent)
+ return
+ }
+ if catalogResponse.error {
+ isLoading = false
+ reportStoreLoadFailure(catalogResponse.message ?? "Não foi possível carregar o catálogo.", hadExistingContent: hadExistingContent)
+ return
+ }
- let normalizedCatalog = StoreCatalogNormalizer.sanitize(
- categories: catalogResponse.result ?? [],
- storeId: storeId
- )
+ let normalizedCatalog = StoreCatalogNormalizer.sanitize(
+ categories: catalogResponse.result ?? [],
+ storeId: storeId
+ )
- info = infoResponse.result
- categories = normalizedCatalog
- selectedCategoryId = StoreCatalogNormalizer.preferredCategoryId(
- from: normalizedCatalog,
- preferredId: selectedCategoryId
- )
- if let info = infoResponse.result {
- AppContentCache.shared.set(info, for: infoCacheKey, ttl: AppCacheTTL.twoHours)
+ info = infoResponse.result
+ categories = normalizedCatalog
+ selectedCategoryId = StoreCatalogNormalizer.preferredCategoryId(
+ from: normalizedCatalog,
+ preferredId: selectedCategoryId
+ )
+ if let info = infoResponse.result {
+ AppContentCache.shared.set(info, for: infoCacheKey, ttl: AppCacheTTL.twoHours)
+ }
+ AppContentCache.shared.set(normalizedCatalog, for: catalogCacheKey, ttl: AppCacheTTL.twoHours)
+ } else {
+ // Anonymous browsing — public/no-login store detail + catalog
+ // via pedifoods.com.br, mapped onto the same StoreInfoResult /
+ // StoreCatalogCategory models the authenticated path uses
+ // above, so the rest of this view doesn't need to know which
+ // source the data came from.
+ async let publicDetail = PublicLocationService.shared.fetchStoreDetail(identifier: storeId)
+ async let publicProducts = PublicLocationService.shared.fetchStoreProducts(storeId: storeId)
+ let (detail, products) = try await (publicDetail, publicProducts)
+
+ let normalizedCatalog = StoreCatalogNormalizer.sanitize(categories: products, storeId: storeId)
+ let publicInfo = StoreInfoResult(publicDetail: detail)
+
+ info = publicInfo
+ categories = normalizedCatalog
+ selectedCategoryId = StoreCatalogNormalizer.preferredCategoryId(
+ from: normalizedCatalog,
+ preferredId: selectedCategoryId
+ )
+ AppContentCache.shared.set(publicInfo, for: infoCacheKey, ttl: AppCacheTTL.twoHours)
+ AppContentCache.shared.set(normalizedCatalog, for: catalogCacheKey, ttl: AppCacheTTL.twoHours)
}
- AppContentCache.shared.set(normalizedCatalog, for: catalogCacheKey, ttl: AppCacheTTL.twoHours)
errorMessage = nil
isLoading = false
} catch {