[2026-07-resubmission] Add guest browsing flow with App Attest session for App Review resubmission
Adds a pre-login public store locator (guest session via DeviceCheck/App Attest, keychain-backed token storage) so the app no longer forces sign-in before showing any content, plus updated support URL metadata.
This commit is contained in:
@@ -2,5 +2,7 @@
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.developer.devicecheck.appattest-environment</key>
|
||||
<string>development</string>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -1 +1 @@
|
||||
https://example.org/support/
|
||||
https://pedifoods.com.br/support
|
||||
|
||||
@@ -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,9 +167,23 @@ 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.
|
||||
// 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 {
|
||||
@@ -182,6 +196,7 @@ struct ContentView: View {
|
||||
} catch {
|
||||
// Keep local state when backend refresh fails transiently
|
||||
}
|
||||
}
|
||||
|
||||
await refreshFeatureFlags(forceRefresh: false)
|
||||
isBootstrappingSession = false
|
||||
@@ -418,6 +433,7 @@ struct AddressPickerModalView: View {
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
if appState.session.isAuthenticated {
|
||||
AddressesView(
|
||||
message: appState.address.onboardingMessage,
|
||||
appState: $appState,
|
||||
@@ -426,6 +442,9 @@ struct AddressPickerModalView: View {
|
||||
.onAppear {
|
||||
appState.address.onboardingMessage = nil
|
||||
}
|
||||
} else {
|
||||
PublicLocationPickerView(appState: $appState)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 <token>` 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<T: Decodable>(_ 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<T: Decodable>(_ 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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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<T: Decodable>(_ req: ApiRequest) async throws -> ApiEnvelope<T> {
|
||||
let envelope: ApiEnvelope<T> = 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<T>(_ envelope: ApiEnvelope<T>) -> 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<EmptyResult> {
|
||||
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<ChangePaymentMethodResult> {
|
||||
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)
|
||||
|
||||
77
Sources/PediFoods/Services/GuestLocationStore.swift
Normal file
77
Sources/PediFoods/Services/GuestLocationStore.swift
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
222
Sources/PediFoods/Services/GuestSessionService.swift
Normal file
222
Sources/PediFoods/Services/GuestSessionService.swift
Normal file
@@ -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<String, Error>?
|
||||
|
||||
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<ChallengeResult> = 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<GuestSessionResult> = 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
|
||||
}
|
||||
}
|
||||
60
Sources/PediFoods/Services/KeychainStore.swift
Normal file
60
Sources/PediFoods/Services/KeychainStore.swift
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
117
Sources/PediFoods/Services/PublicLocationModels.swift
Normal file
117
Sources/PediFoods/Services/PublicLocationModels.swift
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
110
Sources/PediFoods/Services/PublicLocationService.swift
Normal file
110
Sources/PediFoods/Services/PublicLocationService.swift
Normal file
@@ -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<PublicStoreDetail> = 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<T: Decodable & Sendable>(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<T: Decodable & Sendable>(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<T> = 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
@@ -211,10 +201,7 @@ struct HomeView: View {
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var storeCardsList: some View {
|
||||
VStack(spacing: 16) {
|
||||
ForEach(filteredStoreCards) { store in
|
||||
NavigationLink {
|
||||
private func storeDestination(for store: FeaturedStoreCardModel) -> some View {
|
||||
StoreDetailView(
|
||||
storeId: store.id,
|
||||
storeName: store.name,
|
||||
@@ -226,6 +213,14 @@ struct HomeView: View {
|
||||
storeDeliveryFee: store.deliveryFee,
|
||||
appState: $appState
|
||||
)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var storeCardsList: some View {
|
||||
VStack(spacing: 16) {
|
||||
ForEach(filteredStoreCards) { store in
|
||||
NavigationLink {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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 {
|
||||
if appState.session.isAuthenticated {
|
||||
ProfileView(root: $root, selectedTab: $selectedTab, tokenStore: tokenStore, appState: $appState)
|
||||
} else {
|
||||
ProfileLoggedOutView(root: $root)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
174
Sources/PediFoods/Views/Main/PublicLocationPickerView.swift
Normal file
174
Sources/PediFoods/Views/Main/PublicLocationPickerView.swift
Normal file
@@ -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()))
|
||||
}
|
||||
}
|
||||
@@ -167,6 +167,7 @@ extension StoreDetailView {
|
||||
}
|
||||
|
||||
do {
|
||||
if appState.session.isAuthenticated {
|
||||
let apiService = ApiService()
|
||||
let infoResponse = try await apiService.storeInfo(storeId: storeId)
|
||||
let catalogResponse = try await apiService.storeCatalog(storeId: storeId)
|
||||
@@ -197,6 +198,28 @@ extension StoreDetailView {
|
||||
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)
|
||||
}
|
||||
errorMessage = nil
|
||||
isLoading = false
|
||||
} catch {
|
||||
|
||||
Reference in New Issue
Block a user