[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:
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()))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user