[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:
Daniel Arantes Loverde
2026-07-30 11:35:25 -03:00
parent 3e93196b92
commit 017bd7168f
21 changed files with 1140 additions and 136 deletions

View File

@@ -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) {

View File

@@ -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
}
}

View File

@@ -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
}

View File

@@ -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")

View File

@@ -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)
}
}
}
}

View File

@@ -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 {

View 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()))
}
}

View File

@@ -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 {