migration

This commit is contained in:
Daniel Arantes Loverde
2026-08-11 13:22:02 -03:00
parent c4d6998595
commit 7702836fe7
231 changed files with 4329 additions and 1958 deletions

View File

@@ -0,0 +1,11 @@
{
"colors" : [
{
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@@ -0,0 +1,38 @@
{
"images" : [
{
"filename" : "icon_pattern_1024x1024.png",
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"filename" : "icon_pattern_dark_1024x1024.png",
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "tinted"
}
],
"filename" : "icon_pattern_1024x1024 1.png",
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

View File

@@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@@ -0,0 +1,38 @@
{
"colors" : [
{
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0.969",
"green" : "0.961",
"red" : "0.953"
}
},
"idiom" : "universal"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0.102",
"green" : "0.102",
"red" : "0.102"
}
},
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@@ -0,0 +1,12 @@
{
"images" : [
{
"filename" : "alelocard_logo.png",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

View File

@@ -0,0 +1,12 @@
{
"images" : [
{
"filename" : "amexcard_logo.png",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

View File

@@ -0,0 +1,12 @@
{
"images" : [
{
"filename" : "hipercard_logo.png",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

View File

@@ -0,0 +1,12 @@
{
"images" : [
{
"filename" : "icon_pix.png",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 835 B

View File

@@ -0,0 +1,12 @@
{
"images" : [
{
"filename" : "mastercard_logo.png",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.3 KiB

View File

@@ -0,0 +1,52 @@
{
"images": [
{
"idiom": "universal",
"filename": "pedifoods.png",
"scale": "1x"
},
{
"idiom": "universal",
"scale": "2x"
},
{
"idiom": "universal",
"scale": "3x"
},
{
"idiom": "universal",
"filename": "pedifoods_dark.png",
"scale": "1x",
"appearances": [
{
"appearance": "luminosity",
"value": "dark"
}
]
},
{
"idiom": "universal",
"scale": "2x",
"appearances": [
{
"appearance": "luminosity",
"value": "dark"
}
]
},
{
"idiom": "universal",
"scale": "3x",
"appearances": [
{
"appearance": "luminosity",
"value": "dark"
}
]
}
],
"info": {
"version": 1,
"author": "xcode"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

View File

@@ -0,0 +1,52 @@
{
"images": [
{
"idiom": "universal",
"filename": "pin_image_app.png",
"scale": "1x"
},
{
"idiom": "universal",
"scale": "2x"
},
{
"idiom": "universal",
"scale": "3x"
},
{
"idiom": "universal",
"filename": "pin_image_app_dark.png",
"scale": "1x",
"appearances": [
{
"appearance": "luminosity",
"value": "dark"
}
]
},
{
"idiom": "universal",
"scale": "2x",
"appearances": [
{
"appearance": "luminosity",
"value": "dark"
}
]
},
{
"idiom": "universal",
"scale": "3x",
"appearances": [
{
"appearance": "luminosity",
"value": "dark"
}
]
}
],
"info": {
"version": 1,
"author": "xcode"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

View File

@@ -0,0 +1,12 @@
{
"images" : [
{
"filename" : "placeholder-pizza.png",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 667 KiB

View File

@@ -0,0 +1,12 @@
{
"images" : [
{
"filename" : "placeholder-product.png",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

View File

@@ -0,0 +1,12 @@
{
"images" : [
{
"filename" : "sodexo_logo.png",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

View File

@@ -0,0 +1,12 @@
{
"images" : [
{
"filename" : "tracking-canceled.png",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 406 KiB

View File

@@ -0,0 +1,12 @@
{
"images" : [
{
"filename" : "tracking-completed.png",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 924 KiB

View File

@@ -0,0 +1,12 @@
{
"images" : [
{
"filename" : "tracking-delivering.png",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 951 KiB

View File

@@ -0,0 +1,12 @@
{
"images" : [
{
"filename" : "tracking-pending.png",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 940 KiB

View File

@@ -0,0 +1,12 @@
{
"images" : [
{
"filename" : "tracking-preparing.png",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 926 KiB

View File

@@ -0,0 +1,12 @@
{
"images" : [
{
"filename" : "tracking-ready.png",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 799 KiB

View File

@@ -0,0 +1,12 @@
{
"images" : [
{
"filename" : "visacard_logo.png",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

View File

@@ -0,0 +1,70 @@
import SwiftUI
struct PrimaryButton: View {
let title: String
var fullWidth: Bool = true
var image: Image? = nil
let action: @MainActor @Sendable () -> Void
var body: some View {
Button(action: { action() }) {
HStack {
Text(title)
.font(AppTypography.button)
.tracking(AppTypography.buttonLetterSpacing)
if let image {
image
}
}
.foregroundStyle(AppColors.textInverse)
.frame(maxWidth: fullWidth ? .infinity : nil)
.frame(height: 56)
.background(AppColors.primary)
.clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous))
}
.buttonStyle(.plain)
}
}
struct PrimaryButtonLabel: View {
let title: String
var fullWidth: Bool = true
var body: some View {
Text(title)
.font(AppTypography.button)
.tracking(AppTypography.buttonLetterSpacing)
.foregroundStyle(AppColors.textInverse)
.frame(maxWidth: fullWidth ? .infinity : nil)
.frame(height: 56)
.background(AppColors.primary)
.clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous))
.labelStyle(.titleOnly)
}
}
struct SecondaryButton: View {
let title: String
var fullWidth: Bool = true
var image: Image? = nil
let action: @MainActor @Sendable () -> Void
var body: some View {
Button(action: { action() }) {
HStack {
Text(title)
.font(AppTypography.button)
.tracking(AppTypography.buttonLetterSpacing)
if let image {
image
}
}
.foregroundStyle(AppColors.textPrimary)
.frame(maxWidth: fullWidth ? .infinity : nil)
.frame(height: 56)
.background(AppColors.secondary)
.clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous))
}
.buttonStyle(.plain)
}
}

View File

@@ -0,0 +1,146 @@
import Foundation
import SwiftUI
import UIKit
enum ImageFitMode {
/// Scale to cover the whole box, cropping whichever axis overflows.
/// Default matches historical behavior for every image in the app.
case fill
/// Scale so height always matches the box exactly; width follows the
/// source's aspect ratio and gets cropped/gapped on the sides. Opt-in
/// only used by the Store Detail cover header.
case heightFit
}
struct CachedRemoteImage<Placeholder: View>: View {
let imageURL: String?
let ttl: TimeInterval
let fitMode: ImageFitMode
let placeholder: Placeholder
@StateObject var loader = CachedRemoteImageLoader()
init(
imageURL: String?,
ttl: TimeInterval = AppCacheTTL.twoHours,
fitMode: ImageFitMode = .fill,
@ViewBuilder placeholder: () -> Placeholder
) {
self.imageURL = imageURL
self.ttl = ttl
self.fitMode = fitMode
self.placeholder = placeholder()
}
var body: some View {
GeometryReader { geometry in
Group {
if let uiImage = loader.uiImage {
rendered(image: Image(uiImage: uiImage), pixelSize: uiImage.size, in: geometry.size)
} else {
placeholder
}
}
.frame(width: geometry.size.width, height: geometry.size.height)
.clipped()
}
.onAppear {
loader.load(imageURL: imageURL, ttl: ttl)
}
.onChange(of: imageURL) { _, newValue in
loader.load(imageURL: newValue, ttl: ttl)
}
}
@ViewBuilder
private func rendered(image: Image, pixelSize: CGSize, in containerSize: CGSize) -> some View {
switch fitMode {
case .fill:
image
.resizable()
.scaledToFill()
case .heightFit:
let aspect = pixelSize.height > 0 ? pixelSize.width / pixelSize.height : 1
let renderWidth = containerSize.height * aspect
image
.resizable()
.frame(width: renderWidth, height: containerSize.height)
.frame(width: containerSize.width, height: containerSize.height)
}
}
}
@MainActor
final class CachedRemoteImageLoader: ObservableObject {
@Published var uiImage: UIImage?
private var currentKey: String?
private var task: Task<Void, Never>?
deinit {
task?.cancel()
}
func load(imageURL: String?, ttl: TimeInterval) {
let normalized = Self.normalizeImageSource(imageURL)
let key = normalized ?? ""
guard currentKey != key else { return }
currentKey = key
task?.cancel()
uiImage = nil
guard let normalized, normalized.isEmpty == false else { return }
let dataCacheKey = Self.dataURLCacheKey(normalized)
if let cachedDataImage: PlatformImage = AppContentCache.shared.value(for: dataCacheKey, as: PlatformImage.self) {
uiImage = cachedDataImage
return
}
if let image = Self.imageFromDataURL(normalized) {
AppContentCache.shared.set(image, for: dataCacheKey, ttl: ttl)
uiImage = image
return
}
guard let url = URL(string: normalized) else { return }
task = Task { [weak self] in
let image = await AppImageCache.shared.image(for: url, ttl: ttl)
guard Task.isCancelled == false else { return }
await MainActor.run {
self?.uiImage = image
}
}
}
private static func normalizeImageSource(_ value: String?) -> String? {
ImageSourceResolver.resolve(value)
}
private static func dataURLCacheKey(_ source: String) -> String {
let head = String(source.prefix(48))
let tail = String(source.suffix(48))
return "data-image:\(source.count):\(head):\(tail)"
}
private static func imageFromDataURL(_ source: String) -> PlatformImage? {
let lower = source.lowercased()
guard lower.hasPrefix("data:image"), let commaIndex = source.firstIndex(of: ",") else { return nil }
let header = String(source[..<commaIndex]).lowercased()
guard header.contains(";base64") else { return nil }
let payloadStart = source.index(after: commaIndex)
let payload = String(source[payloadStart...])
.replacingOccurrences(of: "\\/", with: "/")
.replacingOccurrences(of: "\n", with: "")
.replacingOccurrences(of: "\r", with: "")
.replacingOccurrences(of: " ", with: "")
guard let data = Data(base64Encoded: payload, options: [.ignoreUnknownCharacters]) else { return nil }
return UIImage(data: data)
}
}

View File

@@ -0,0 +1,43 @@
import SwiftUI
struct SearchField: View {
var placeholder: String
@Binding var text: String
var body: some View {
HStack(spacing: 12) {
Image(systemName: "magnifyingglass")
.foregroundStyle(AppColors.primary)
TextField(placeholder, text: $text)
.appNoAutoCap()
}
.padding(.horizontal, 16)
.frame(height: 52)
.background(AppColors.backgroundLight)
.clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous)
.stroke(AppColors.primary.opacity(0.15), lineWidth: 1)
)
}
}
struct PillButton: View {
let title: String
let isActive: Bool
var body: some View {
Text(title)
.font(AppTypography.caption)
.tracking(AppTypography.captionLetterSpacing)
.foregroundStyle(isActive ? AppColors.textInverse : AppColors.primary)
.padding(.horizontal, 14)
.padding(.vertical, 8)
.background(isActive ? AppColors.primary : AppColors.backgroundLight)
.clipShape(Capsule())
.overlay(
Capsule()
.stroke(AppColors.primary.opacity(0.15), lineWidth: isActive ? 0 : 1)
)
}
}

View File

@@ -0,0 +1,38 @@
import SwiftUI
struct SnackbarOverlay: View {
@ObservedObject var center: SnackbarCenter
var body: some View {
VStack {
if let message = center.current {
HStack(spacing: 10) {
if let icon = message.iconSystemName, !icon.isEmpty {
Image(systemName: icon)
.font(.system(size: 16, weight: .semibold))
}
Text(message.title)
.font(AppTypography.heading3)
.multilineTextAlignment(.leading)
.lineLimit(3)
Spacer(minLength: 0)
}
.foregroundStyle(Color.white)
.padding(.top, 14)
.padding(.horizontal, 16)
.padding(.bottom, 16)
.frame(maxWidth: .infinity, alignment: .leading)
.background(message.style.backgroundColor)
.appContentShape(Rectangle())
.onTapGesture {
center.handleTap()
}
.transition(.move(edge: .top).combined(with: .opacity))
.zIndex(999)
}
Spacer()
}
.animation(.spring(response: 0.3, dampingFraction: 0.9), value: center.current?.id)
.allowsHitTesting(center.current != nil)
}
}

View File

@@ -0,0 +1,173 @@
import SwiftUI
struct FeaturedStoreCard: View {
let store: FeaturedStoreCardModel
var onFavoriteToggle: (() -> Void)? = nil
var body: some View {
VStack(alignment: .leading, spacing: 12) {
ZStack(alignment: .topLeading) {
mediaBlock
if let promo = store.promoText {
Text(promo)
.font(AppTypography.caption)
.foregroundStyle(AppColors.textInverse)
.padding(.horizontal, 10)
.padding(.vertical, 6)
.background(Color.red)
.clipShape(Capsule())
.padding(10)
}
HStack {
Spacer()
Button {
onFavoriteToggle?()
} label: {
Image(systemName: store.isFavorite ? "heart.fill" : "heart")
.foregroundStyle(store.isFavorite ? Color.red : AppColors.textMuted)
.padding(8)
.background(AppColors.surface)
.clipShape(Circle())
.shadow(color: AppShadow.soft.color, radius: AppShadow.soft.radius, y: AppShadow.soft.y)
}
.buttonStyle(.borderless)
.accessibilityLabel(store.isFavorite ? "Remover loja dos favoritos" : "Adicionar loja aos favoritos")
.padding(10)
}
}
Text(store.name)
.font(AppTypography.heading3)
.foregroundStyle(AppColors.textPrimary)
if store.isOpen {
HStack(spacing: 6) {
Image(systemName: "star.fill")
.font(.caption)
.foregroundStyle(Color(hex: "#F5B335"))
Text(String(format: "%.1f", store.rating))
.font(.caption)
.foregroundStyle(AppColors.textPrimary)
Text("(\(store.reviews))")
.font(.caption)
.foregroundStyle(AppColors.textMuted)
Text("·")
.font(.caption)
.foregroundStyle(AppColors.textMuted)
Text(store.distance)
.font(.caption)
.foregroundStyle(AppColors.textMuted)
}
} else {
Text(store.statusLabel?.isEmpty == false ? (store.statusLabel ?? "Fechado") : "Fechado")
.font(AppTypography.heading3)
.foregroundStyle(AppColors.textPrimary)
}
Text(store.category)
.font(.caption)
.foregroundStyle(AppColors.textMuted)
}
.padding(14)
.background(AppColors.surface)
.clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusXL, style: .continuous))
.saturation(store.isOpen ? 1 : 0)
.opacity(store.isOpen ? 1 : 0.9)
}
private var mediaBlock: some View {
ZStack {
RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous)
.fill(AppColors.brandSoft)
mediaImage
}
.frame(height: 120)
.clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous))
}
@ViewBuilder
private var mediaImage: some View {
AsyncStoreImage(imageURL: store.imageURL)
}
private var storeIconPlaceholder: some View {
ZStack {
Image("placeholder-product")
.resizable()
.scaledToFill()
.opacity(0.7)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.clipped()
Circle()
.fill(AppColors.surface.opacity(0.92))
.frame(width: 64, height: 64)
.overlay(
Image(systemName: store.iconName)
.font(.title2)
.foregroundStyle(AppColors.primary)
)
}
}
}
struct FeaturedStoreCardModel: Identifiable {
let id: String
let name: String
let rating: Double
let reviews: String
let distance: String
let deliveryFee: Double?
let category: String
let promoText: String?
let isFavorite: Bool
let iconName: String
let imageURL: String?
let logoURL: String?
let coverURL: String?
let isOpen: Bool
let statusLabel: String?
}
struct SpecialOfferCard: View {
let model: SpecialOfferCardModel
var body: some View {
ZStack(alignment: .leading) {
RoundedRectangle(cornerRadius: AppLayout.radiusXL, style: .continuous)
.fill(
LinearGradient(
colors: model.colors,
startPoint: .leading,
endPoint: .trailing
)
)
Circle()
.fill(Color.white.opacity(0.18))
.frame(width: 120, height: 120)
.offset(x: 140, y: 10)
VStack(alignment: .leading, spacing: 8) {
Text(model.title)
.font(AppTypography.heading2)
.foregroundStyle(AppColors.textInverse)
Text(model.subtitle)
.font(AppTypography.body)
.foregroundStyle(AppColors.textInverse.opacity(0.85))
}
.padding(20)
}
.shadow(color: AppShadow.soft.color, radius: AppShadow.soft.radius, y: AppShadow.soft.y)
}
}
struct SpecialOfferCardModel: Identifiable {
let id: String
let title: String
let subtitle: String
let colors: [Color]
}

View File

@@ -0,0 +1,25 @@
import SwiftUI
extension View {
@ViewBuilder
func appNoAutoCap() -> some View {
#if os(iOS)
self
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
#else
self
#endif
}
@ViewBuilder
func appOTPKeyboard() -> some View {
#if os(iOS)
self
.keyboardType(.numberPad)
.textContentType(.oneTimeCode)
#else
self
#endif
}
}

531
PediFoods/ContentView.swift Normal file
View File

@@ -0,0 +1,531 @@
import Foundation
import SwiftUI
enum DeepLinkRouteEffect: Equatable {
case navigateToOrder(OrderRouteContext, tab: MainTab)
case none
}
struct ContentView: View {
@State var root: RootFlow = .main
@State var selectedTab: MainTab = .home
private let tokenStore: TokenStore = DefaultTokenStore()
@State var appState = AppState()
@State var isBootstrappingSession = false
@State var hasPerformedInitialLaunchBootstrap = false
@State var showLaunchSplash = true
@State var shouldPulseLaunchSplash = true
@State var shouldPrepareAuthEntryAnimation = DefaultTokenStore().jwt == nil
@State var authEntryAnimationToken = 0
@State private var sessionExpiredObserver: NSObjectProtocol?
@State var cartResetObserver: Any?
@State var appResumeObserver: Any?
@State var pushDeepLinkObserver: Any?
@StateObject var snackbarCenter = SnackbarCenter.shared
var body: some View {
ZStack(alignment: .top) {
Group {
switch root {
case .auth:
AuthFlowView(
root: $root,
selectedTab: $selectedTab,
tokenStore: tokenStore,
appState: $appState,
shouldPrepareLoginEntry: shouldPrepareAuthEntryAnimation,
authEntryAnimationToken: authEntryAnimationToken
)
case .main:
if isBootstrappingSession {
sessionBootstrapLoadingView
} else {
MainTabView(selectedTab: $selectedTab, tokenStore: tokenStore, appState: $appState, enterAuth: enterAuthFlow)
}
}
}
SnackbarOverlay(center: snackbarCenter)
if showLaunchSplash {
LaunchSplashView(shouldPulse: shouldPulseLaunchSplash)
.transition(.opacity)
}
}
.sheet(item: $appState.activeModal) { modal in
switch modal {
case .addressPicker:
AddressPickerModalView(appState: $appState, selectedTab: $selectedTab)
case .filters:
FiltersModalView(appState: $appState)
}
}
.onChange(of: appState.address.display) { _, _ in
dismissAddressPickerIfAddressExists()
}
.onChange(of: appState.address.selectedId) { _, _ in
dismissAddressPickerIfAddressExists()
}
.onChange(of: appState.address.latitude) { _, _ in
dismissAddressPickerIfAddressExists()
}
.onChange(of: appState.address.longitude) { _, _ in
dismissAddressPickerIfAddressExists()
}
.task {
await performInitialLaunchBootstrap()
}
.onChange(of: root) { _, newValue in
if newValue == .main {
Task {
await bootstrapSessionStateIfNeeded()
}
}
}
.onChange(of: appState.profile.id) { _, _ in
Task { @MainActor in
await refreshFeatureFlags(forceRefresh: true)
}
}
.onChange(of: appState.cart.storeId) { _, _ in
Task { @MainActor in
await refreshFeatureFlags(forceRefresh: true)
}
}
.onAppear {
attachCartResetObserverIfNeeded()
attachAppResumeObserverIfNeeded()
attachSessionExpiredObserverIfNeeded()
attachPushDeepLinkObserverIfNeeded()
}
.onDisappear {
detachCartResetObserver()
detachAppResumeObserver()
detachSessionExpiredObserver()
detachPushDeepLinkObserver()
}
}
private var sessionBootstrapLoadingView: some View {
VStack(spacing: 12) {
ProgressView()
Text("Carregando sua sessão...")
.font(AppTypography.body)
.foregroundStyle(AppColors.textMuted)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(AppColors.backgroundLight)
}
@MainActor
private func performInitialLaunchBootstrap() async {
guard hasPerformedInitialLaunchBootstrap == false else { return }
hasPerformedInitialLaunchBootstrap = true
let start = Date()
await bootstrapSessionStateIfNeeded()
// Keep the in-app splash visible long enough to avoid abrupt transition
// between native launch screen and app content.
let elapsed = Date().timeIntervalSince(start)
let minimumSplashDuration: TimeInterval = 1.0
if elapsed < minimumSplashDuration {
let remaining = minimumSplashDuration - elapsed
let nanoseconds = UInt64(remaining * 1_000_000_000)
try? await Task.sleep(nanoseconds: nanoseconds)
}
if root == .auth {
shouldPrepareAuthEntryAnimation = true
shouldPulseLaunchSplash = false
try? await Task.sleep(nanoseconds: 180_000_000)
withAnimation(.easeInOut(duration: 0.34)) {
showLaunchSplash = false
}
try? await Task.sleep(nanoseconds: 360_000_000)
authEntryAnimationToken += 1
scheduleDisableAuthEntryPreparation()
return
}
shouldPulseLaunchSplash = false
withAnimation(.easeOut(duration: 0.28)) {
showLaunchSplash = false
}
}
@MainActor
private func bootstrapSessionStateIfNeeded() async {
guard root == .main else { return }
guard isBootstrappingSession == false else { return }
isBootstrappingSession = true
if let jwt = tokenStore.jwt {
appState.session.jwt = jwt
appState.session.isAuthenticated = true
}
if let cachedAddress = SessionStateStore.loadAddress() {
appState.address = cachedAddress
dismissAddressPickerIfAddressExists()
}
if let cachedCart = SessionStateStore.loadCart() {
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 {
hydrateAppState(with: customer)
}
} catch let error as ApiServiceError {
if case .sessionExpired = error {
forceLogoutToStart()
}
} catch {
// Keep local state when backend refresh fails transiently
}
}
await refreshFeatureFlags(forceRefresh: false)
isBootstrappingSession = false
}
private func attachCartResetObserverIfNeeded() {
guard cartResetObserver == nil else { return }
cartResetObserver = NotificationCenter.default.addObserver(
forName: .cartDidReset,
object: nil,
queue: nil
) { _ in
Task { @MainActor in
appState.cart = CartState()
}
}
}
private func detachCartResetObserver() {
guard let cartResetObserver else { return }
NotificationCenter.default.removeObserver(cartResetObserver)
self.cartResetObserver = nil
}
private func attachAppResumeObserverIfNeeded() {
guard appResumeObserver == nil else { return }
appResumeObserver = NotificationCenter.default.addObserver(
forName: .appDidResume,
object: nil,
queue: nil
) { _ in
Task { @MainActor in
guard root == .main else { return }
await refreshFeatureFlags(forceRefresh: true)
}
}
}
private func detachAppResumeObserver() {
guard let appResumeObserver else { return }
NotificationCenter.default.removeObserver(appResumeObserver)
self.appResumeObserver = nil
}
/// §6 of the push notifications guide, generalized: any tapped push that
/// resolves to a `DeepLinkDestination` (reported by
/// `PushNotificationCoordinator`) lands here. Adding a future promo/coupon
/// screen means adding one case to `route(to:)` below this observer,
/// the notification name, and the AppState plumbing for it stay put.
private func attachPushDeepLinkObserverIfNeeded() {
guard pushDeepLinkObserver == nil else { return }
pushDeepLinkObserver = NotificationCenter.default.addObserver(
forName: .pushDeepLinkReceived,
object: nil,
queue: nil
) { notification in
guard let destination = notification.userInfo?["destination"] as? DeepLinkDestination else { return }
Task { @MainActor in
route(to: destination)
}
}
}
private func detachPushDeepLinkObserver() {
guard let pushDeepLinkObserver else { return }
NotificationCenter.default.removeObserver(pushDeepLinkObserver)
self.pushDeepLinkObserver = nil
}
/// Pure mapping from a deep-link destination to what `route(to:)` should
/// do - split out so it's testable without a live `ContentView`
/// instance (this reads no `@State`, `route(to:)` is what applies the
/// result to `appState`/`selectedTab`).
func routeEffect(for destination: DeepLinkDestination) -> DeepLinkRouteEffect {
switch destination {
case .orderTracking(let orderId, let shortId):
return .navigateToOrder(
OrderRouteContext(
orderId: orderId,
shortId: shortId,
paymentMethod: nil,
total: nil,
intent: .auto
),
tab: .profile
)
case .screen(let name, let params):
// Promo/coupon screens (participating stores/products, §6's
// `targetScreen` convention) land here once they exist see
// decisions/2026-08-04-push-deeplink-routing-contract.md.
logger.debug("Unhandled deep-link screen: \(name, privacy: .public) params=\(params, privacy: .public)")
return .none
}
}
@MainActor
private func route(to destination: DeepLinkDestination) {
guard root == .main else { return }
switch routeEffect(for: destination) {
case .navigateToOrder(let context, let tab):
appState.pendingOrderDeepLink = context
selectedTab = tab
case .none:
break
}
}
@MainActor
private func hydrateAppState(with customer: CustomerProfile) {
appState.profile.id = customer.id
appState.profile.name = customer.name
appState.profile.email = customer.email
appState.profile.phone = customer.phoneNumber ?? ""
appState.profile.profilePicture = customer.profilePicture ?? ""
appState.favorites.storeIds = Set(customer.favorites ?? [])
SessionStateStore.setActiveUserKey(
SessionStateStore.makeUserKey(profileId: customer.id, email: customer.email)
)
if let cachedCart = SessionStateStore.loadCart() {
appState.cart = cachedCart
} else {
appState.cart = CartState()
}
let addresses = customer.addressBook ?? []
guard addresses.isEmpty == false else {
appState.address = AddressState()
SessionStateStore.clearAddress()
return
}
let preferredAddress = resolvePreferredAddress(from: addresses, current: appState.address)
if let preferredAddress {
applyAddress(preferredAddress)
SessionStateStore.saveAddress(appState.address)
}
}
private func resolvePreferredAddress(from addresses: [CustomerAddress], current: AddressState) -> CustomerAddress? {
guard addresses.isEmpty == false else { return nil }
if let selectedId = current.selectedId?.trimmingCharacters(in: .whitespacesAndNewlines),
selectedId.isEmpty == false,
let byId = addresses.first(where: { ($0.id ?? "").trimmingCharacters(in: .whitespacesAndNewlines) == selectedId }) {
return byId
}
let normalizedDisplay = current.display
.trimmingCharacters(in: .whitespacesAndNewlines)
.folding(options: [.diacriticInsensitive, .caseInsensitive], locale: .current)
.lowercased()
if normalizedDisplay.isEmpty == false && normalizedDisplay != "defina seu endereco",
let byLabel = addresses.first(where: {
(($0.label ?? "")
.trimmingCharacters(in: .whitespacesAndNewlines)
.folding(options: [.diacriticInsensitive, .caseInsensitive], locale: .current)
.lowercased()) == normalizedDisplay
}) {
return byLabel
}
if let lat = current.latitude, let lng = current.longitude,
let byCoordinate = addresses.first(where: { address in
guard let addrLat = address.latLong?.first,
let addrLng = address.latLong?.dropFirst().first else { return false }
return abs(addrLat - lat) < 0.00001 && abs(addrLng - lng) < 0.00001
}) {
return byCoordinate
}
return addresses.first
}
@MainActor
private func applyAddress(_ address: CustomerAddress) {
appState.address.selectedId = address.id
let cleanLabel = (address.label ?? "")
.trimmingCharacters(in: .whitespacesAndNewlines)
appState.address.display = cleanLabel.isEmpty ? "Defina seu endereco" : cleanLabel
if let lat = address.latLong?.first, let lng = address.latLong?.dropFirst().first {
appState.address.latitude = lat
appState.address.longitude = lng
} else {
appState.address.latitude = nil
appState.address.longitude = nil
}
}
@MainActor
private func forceLogoutToStart() {
tokenStore.clear()
SessionStateStore.clearActiveUser()
SessionStateStore.clearTrackedOrders()
SessionStateStore.clearPendingCartOrder()
AppContentCache.shared.invalidate()
AppImageCache.shared.invalidateAll()
isBootstrappingSession = false
appState = AppState()
selectedTab = .home
enterAuthFlow()
}
/// The only correct way to transition `root` to `.auth`: pairs the
/// switch with the token bump `LoginView` needs to run its entry
/// reveal animation. A bare `root = .auth` leaves the login screen's
/// hero/text/button stuck hidden - see
/// decisions/2026-08-06-login-entry-animation-bug.md.
@MainActor
private func enterAuthFlow() {
shouldPrepareAuthEntryAnimation = true
root = .auth
authEntryAnimationToken += 1
scheduleDisableAuthEntryPreparation()
}
@MainActor
private func refreshFeatureFlags(forceRefresh: Bool) async {
guard root == .main else { return }
let subjectType: String
let subjectId: String
if let profileId = appState.profile.id?.trimmingCharacters(in: .whitespacesAndNewlines), profileId.isEmpty == false {
subjectType = "customer"
subjectId = profileId
} else {
subjectType = "anonymous"
subjectId = "anonymous-device"
}
var attrs: [String: String] = [:]
let addressLabel = appState.address.display.trimmingCharacters(in: .whitespacesAndNewlines)
if addressLabel.isEmpty == false, addressLabel.lowercased() != "defina seu endereco" {
attrs["addressLabel"] = addressLabel
}
let context = FeatureControlEvaluationContext(
subjectType: subjectType,
subjectId: subjectId,
storeId: appState.cart.storeId,
attributes: attrs
)
let snapshot = await FeatureControlService.shared.evaluate(
context: context,
jwt: appState.session.jwt,
forceRefresh: forceRefresh
)
appState.featureFlags = snapshot
await FeatureControlService.shared.sendExposureEvents(
snapshot: snapshot,
context: context,
jwt: appState.session.jwt
)
}
private func scheduleDisableAuthEntryPreparation() {
Task { @MainActor in
try? await Task.sleep(nanoseconds: 900_000_000)
shouldPrepareAuthEntryAnimation = false
}
}
private func hasConfiguredAddress() -> Bool {
if appState.address.selectedId != nil {
return true
}
let normalized = appState.address.display
.trimmingCharacters(in: .whitespacesAndNewlines)
.lowercased()
return normalized.isEmpty == false && normalized != "defina seu endereco"
}
@MainActor
private func dismissAddressPickerIfAddressExists() {
guard appState.activeModal == .addressPicker else { return }
if hasConfiguredAddress() {
appState.activeModal = nil
}
}
private func attachSessionExpiredObserverIfNeeded() {
guard sessionExpiredObserver == nil else { return }
sessionExpiredObserver = NotificationCenter.default.addObserver(
forName: .sessionExpired,
object: nil,
queue: .main
) { _ in
Task { @MainActor in
forceLogoutToStart()
}
}
}
private func detachSessionExpiredObserver() {
guard let observer = sessionExpiredObserver else { return }
NotificationCenter.default.removeObserver(observer)
sessionExpiredObserver = nil
}
}
struct AddressPickerModalView: View {
@Binding var appState: AppState
@Binding var selectedTab: MainTab
var body: some View {
NavigationStack {
if appState.session.isAuthenticated {
AddressesView(
message: appState.address.onboardingMessage,
appState: $appState,
selectionMode: true
)
.onAppear {
appState.address.onboardingMessage = nil
}
} else {
PublicLocationPickerView(appState: $appState)
}
}
}
}

View File

@@ -0,0 +1,10 @@
enum RootFlow: Hashable {
case auth
case main
}
enum MainTab: Hashable {
case home
case cart
case profile
}

View File

@@ -0,0 +1,49 @@
import Foundation
/// Single decode target for anything that can deep-link into the app push
/// notification taps today (docs/api/push-notifications-integration-guide.md
/// §6), in-app promo/discount banner taps tomorrow (same shape: a string-keyed
/// dictionary naming a screen + its params). Adding a new destination means
/// adding one case here + one branch in `ContentView.route(to:)` never a new
/// `NotificationCenter` name, `AppState` field, or observer pair.
enum DeepLinkDestination: Equatable {
/// `type: "order_status"` the only push type Atomenta fixes today
/// (§6), so it gets its own case instead of routing through `targetScreen`.
case orderTracking(orderId: String, shortId: String?)
/// Everything else, keyed by `targetScreen` our own convention (§6
/// leaves this to us). Once a real screen exists for a given name (e.g.
/// future promo/coupon "list of participating stores/products"), give it
/// its own case; until then this carries the raw params so nothing is
/// silently dropped.
case screen(name: String, params: [String: String])
}
/// Resolves the *navigation* half of a tapped push's payload. Pure and
/// side-effect-free on purpose: easy to unit test, and reusable as-is for
/// in-app banner taps once those exist. Campaign-open reporting (§6a) is a
/// separate side effect, not a navigation target, and is not decided here
/// see `PushNotificationCoordinator.handleTap`.
struct PushDeepLinkParser {
static func parse(_ userInfo: [AnyHashable: Any]) -> DeepLinkDestination? {
if let type = userInfo["type"] as? String, type == "order_status" {
// Atomenta's real order-status push (PushNotificationService.
// notifyOrderStatusUpdate) sends only `shortId`, never `orderId`
// `GET /api/public/orders/:orderId` accepts either as the lookup
// key server-side, so `shortId` doubles as the id here too.
let shortId = userInfo["shortId"] as? String
guard let orderId = (userInfo["orderId"] as? String) ?? shortId else { return nil }
return .orderTracking(orderId: orderId, shortId: shortId)
}
guard let targetScreen = userInfo["targetScreen"] as? String else { return nil }
var params: [String: String] = [:]
for (key, value) in userInfo {
guard let key = key as? String, key != "type", key != "targetScreen" else { continue }
if let stringValue = value as? String {
params[key] = stringValue
}
}
return .screen(name: targetScreen, params: params)
}
}

View File

@@ -0,0 +1,125 @@
import SwiftUI
import UIKit
// MARK: - Colors
struct AppColors {
private init() {}
// Brandbook 2026 (Pedi Foods)
// Primary now follows the "FOODS" dark green tone from the logo.
static let primary = Color(.sRGB, red: 52/255.0, green: 93/255.0, blue: 84/255.0, opacity: 1.0)
// Secondary keeps the vivid lime from the symbol/logo body.
static let secondary = Color(.sRGB, red: 213/255.0, green: 216/255.0, blue: 65/255.0, opacity: 1.0)
static let tertiary = Color(.sRGB, red: 167/255.0, green: 191/255.0, blue: 66/255.0, opacity: 1.0)
static let brandDark = Color(.sRGB, red: 31/255.0, green: 45/255.0, blue: 64/255.0, opacity: 1.0)
static let brandSoft = Color(.sRGB, red: 242/255.0, green: 245/255.0, blue: 227/255.0, opacity: 1.0)
static let backgroundLight = Color(.sRGB, red: 243/255.0, green: 245/255.0, blue: 247/255.0, opacity: 1.0)
static let backgroundDark = Color(.sRGB, red: 31/255.0, green: 45/255.0, blue: 64/255.0, opacity: 1.0)
static let surface = Color.white
static let textPrimary = Color.black
static let textInverse = Color.white
static let textMuted = Color(.sRGB, red: 102/255.0, green: 112/255.0, blue: 133/255.0, opacity: 1.0)
}
struct AppDarkColors {
private init() {}
static let background = Color(.sRGB, red: 31/255.0, green: 45/255.0, blue: 64/255.0, opacity: 1.0)
static let surface = Color.white.opacity(0.08)
static let textPrimary = Color.white
static let textSecondary = Color(.sRGB, red: 208/255.0, green: 213/255.0, blue: 221/255.0, opacity: 1.0)
static let primary = AppColors.primary
static let secondary = AppColors.secondary
static let tertiary = AppColors.tertiary
}
// MARK: - Typography
struct AppTypography {
private init() {}
// Brandbook typography: Nexa (fallback: system font)
static let fontFamily = "Nexa-Regular"
static let heading1 = resolvedFont(size: 28, fallbackWeight: .bold)
static let heading25 = resolvedFont(size: 25, fallbackWeight: .bold)
static let heading2 = resolvedFont(size: 20, fallbackWeight: .semibold)
static let heading3 = resolvedFont(size: 16, fallbackWeight: .semibold)
static let body = resolvedFont(size: 16, fallbackWeight: .regular)
static let button = resolvedFont(size: 14, fallbackWeight: .semibold)
static let caption = resolvedFont(size: 10, fallbackWeight: .regular)
static let overline = resolvedFont(size: 11, fallbackWeight: .regular)
static let bodyLineHeight: CGFloat = 1.6
static let buttonLetterSpacing: CGFloat = 0.08
static let captionLetterSpacing: CGFloat = 0.12
private static func resolvedFont(size: CGFloat, fallbackWeight: Font.Weight) -> Font {
if isBrandFontAvailable {
return Font.custom(fontFamily, size: size)
}
return .system(size: size, weight: fallbackWeight, design: .default)
}
private static var isBrandFontAvailable: Bool {
UIFont(name: fontFamily, size: 16) != nil
}
}
// MARK: - Layout
struct AppLayout {
private init() {}
static let radiusMD: CGFloat = 12
static let radiusLG: CGFloat = 16
static let radiusXL: CGFloat = 24
static let radiusFull: CGFloat = 9999
static let spacing: [CGFloat] = [4, 8, 12, 16, 24, 32]
}
// MARK: - Shadow
struct ShadowSpec {
let color: Color
let radius: CGFloat
let y: CGFloat
}
struct AppShadow {
private init() {}
static let soft = ShadowSpec(color: Color.black.opacity(0.08), radius: 20, y: 6)
static let glow = ShadowSpec(color: AppColors.tertiary.opacity(0.2), radius: 24, y: 8)
}
// MARK: - Helpers
extension Color {
init(hex: String, alpha: Double = 1.0) {
let cleaned = hex.trimmingCharacters(in: CharacterSet.alphanumerics.inverted)
var int: UInt64 = 0
if let val = UInt64(cleaned, radix: 16) {
int = val
}
let r, g, b: UInt64
switch cleaned.count {
case 6: // RRGGBB
r = (int >> 16) & 0xFF
g = (int >> 8) & 0xFF
b = int & 0xFF
case 3: // RGB
r = ((int >> 8) & 0xF) * 17
g = ((int >> 4) & 0xF) * 17
b = (int & 0xF) * 17
default:
r = 0; g = 0; b = 0
}
self.init(.sRGB,
red: Double(r) / 255,
green: Double(g) / 255,
blue: Double(b) / 255,
opacity: alpha)
}
}

5
PediFoods/Info.plist Normal file
View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict/>
</plist>

View File

@@ -0,0 +1,43 @@
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="23504" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" launchScreen="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES" initialViewController="01J-lp-oVM">
<device id="retina6_12" orientation="portrait" appearance="light"/>
<dependencies>
<deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="23506"/>
<capability name="Safe area layout guides" minToolsVersion="9.0"/>
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
</dependencies>
<scenes>
<!--View Controller-->
<scene sceneID="EHf-IW-A2E">
<objects>
<viewController id="01J-lp-oVM" sceneMemberID="viewController">
<view key="view" contentMode="scaleToFill" id="Ze5-6b-2t3">
<rect key="frame" x="0.0" y="0.0" width="393" height="852"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<subviews>
<imageView clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleAspectFit" image="pedifoods" translatesAutoresizingMaskIntoConstraints="NO" id="W6R-s6-hHG">
<constraints>
<constraint firstAttribute="height" constant="160" id="S0g-eL-cqR"/>
</constraints>
</imageView>
</subviews>
<viewLayoutGuide key="safeArea" id="Bcu-3y-fUS"/>
<color key="backgroundColor" name="LaunchBackground"/>
<constraints>
<constraint firstItem="W6R-s6-hHG" firstAttribute="centerX" secondItem="Ze5-6b-2t3" secondAttribute="centerX" id="F0M-uu-fA3"/>
<constraint firstItem="W6R-s6-hHG" firstAttribute="centerY" secondItem="Ze5-6b-2t3" secondAttribute="centerY" id="M3D-C3-C0c"/>
<constraint firstItem="W6R-s6-hHG" firstAttribute="leading" relation="greaterThanOrEqual" secondItem="Bcu-3y-fUS" secondAttribute="leading" constant="24" id="kT5-M8-Yrq"/>
<constraint firstItem="Bcu-3y-fUS" firstAttribute="trailing" relation="greaterThanOrEqual" secondItem="W6R-s6-hHG" secondAttribute="trailing" constant="24" id="rbz-Yf-pdY"/>
</constraints>
</view>
</viewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="iYj-Kq-Ea1" userLabel="First Responder" sceneMemberID="firstResponder"/>
</objects>
<point key="canvasLocation" x="50" y="32"/>
</scene>
</scenes>
<resources>
<image name="pedifoods" width="1024" height="1024"/>
</resources>
</document>

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>aps-environment</key>
<string>development</string>
<key>com.apple.developer.devicecheck.appattest-environment</key>
<string>development</string>
</dict>
</plist>

View File

@@ -0,0 +1,63 @@
import OSLog
import SwiftUI
let logger: Logger = Logger(subsystem: "com.br.pedifoods.app", category: "PediFoods")
@main
struct PediFoodsApp: App {
@UIApplicationDelegateAdaptor(PediFoodsAppDelegate.self) private var appDelegate
@Environment(\.scenePhase) private var scenePhase
var body: some Scene {
WindowGroup {
ContentView()
}
.onChange(of: scenePhase) { oldPhase, newPhase in
switch newPhase {
case .active:
logger.debug("onResume")
NotificationCenter.default.post(name: .appDidResume, object: nil)
case .inactive:
logger.debug("onPause")
case .background:
logger.debug("onStop")
@unknown default:
logger.debug("unknown scene phase: \(String(describing: newPhase))")
}
}
}
}
final class PediFoodsAppDelegate: NSObject, UIApplicationDelegate {
func application(_ application: UIApplication, willFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool {
logger.debug("onInit")
return true
}
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool {
logger.debug("onLaunch")
Task { @MainActor in
PushNotificationCoordinator.shared.startObservingDeviceToken()
PushNotificationCoordinator.shared.becomeNotificationCenterDelegate()
await PushNotificationCoordinator.shared.refreshRegistrationIfAuthorized()
await PushNotificationCoordinator.shared.syncCustomerAttributes()
}
return true
}
func applicationWillTerminate(_ application: UIApplication) {
logger.debug("onDestroy")
}
func applicationDidReceiveMemoryWarning(_ application: UIApplication) {
logger.debug("onLowMemory")
}
func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
NotificationCenter.default.post(name: NSNotification.Name("didRegisterForRemoteNotificationsWithDeviceToken"), object: application, userInfo: ["deviceToken": deviceToken])
}
func application(_ application: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: any Error) {
NotificationCenter.default.post(name: NSNotification.Name("didFailToRegisterForRemoteNotificationsWithError"), object: application, userInfo: ["error": error])
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,83 @@
import Foundation
struct SavedCard: Decodable, Identifiable, Hashable {
let id: String
let nickname: String?
let holderName: String
let last4: String
let brand: String?
let expiryMonth: String
let expiryYear: String
let isDefault: Bool
var displayLabel: String {
if let nickname, nickname.isEmpty == false { return nickname }
let brandLabel = (brand ?? "Cartão").capitalized
return "\(brandLabel) •••• \(last4)"
}
var expiryLabel: String { "\(expiryMonth)/\(expiryYear)" }
}
struct SaveCardCreditCardPayload: Encodable {
let holderName: String
let number: String
let expiryMonth: String
let expiryYear: String
let ccv: String
}
struct SaveCardHolderInfoPayload: Encodable {
let name: String
let email: String
let cpfCnpj: String
let postalCode: String
let addressNumber: String
let phone: String
}
struct SaveCardPayload: Encodable {
let creditCard: SaveCardCreditCardPayload
let creditCardHolderInfo: SaveCardHolderInfoPayload
let nickname: String?
let isDefault: Bool
}
struct UpdateCardPayload: Encodable {
let nickname: String?
let isDefault: Bool?
}
struct SavedCardResult: Decodable {
let id: String
let holderName: String
let last4: String
let brand: String?
let expiryMonth: String
let expiryYear: String
let isDefault: Bool
}
struct CreditCardOrderPayload: Encodable {
let holderName: String
let number: String
let expiryMonth: String
let expiryYear: String
let ccv: String
}
struct ChangePaymentMethodPayload: Encodable {
let paymentMethod: String
let clientCpfCnpj: String?
let creditCard: CreditCardOrderPayload?
let creditCardHolderInfo: SaveCardHolderInfoPayload?
let savedCardId: String?
}
struct ChangePaymentMethodResult: Decodable {
let paymentMethod: String?
let paymentLocation: String?
let paymentId: String?
let paymentPayload: CreateOrderPaymentPayload?
let payment: CreateOrderPaymentInfo?
}

View File

@@ -0,0 +1,498 @@
import Foundation
#if canImport(LCEssentials)
import LCEssentials
#endif
enum NetworkError: Error, LocalizedError {
case invalidURL
case invalidResponse
case httpError(Int, String?)
case unauthorized(String?)
case decodeError(String?)
case rateLimited(Int?)
case cancelled
case timedOut
case transportError(String)
var errorDescription: String? {
switch self {
case .invalidURL: return "URL invalida"
case .invalidResponse: return "Resposta invalida do servidor"
case .httpError(let code, let message):
return message ?? "Erro HTTP (\(code))"
case .unauthorized(let message):
return message ?? "Sessao expirada. Faca login novamente."
case .decodeError(let payload):
if let payload, payload.isEmpty == false {
return "Erro ao interpretar dados: \(payload)"
}
return "Erro ao interpretar dados"
case .rateLimited(let retryAfter):
if let retryAfter {
return "Muitas requisicoes. Tente novamente em \(retryAfter)s."
}
return "Muitas requisicoes. Tente novamente."
case .cancelled:
return "Requisicao cancelada"
case .timedOut:
return "O servidor demorou demais para responder. Tente novamente."
case .transportError(let message):
return "Erro de rede: \(message)"
}
}
}
struct ApiErrorDescriptor {
let code: String?
let message: String?
}
struct ApiRequest: Sendable {
let path: String
let method: String
let module: ApiModule
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,
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
}
}
final class ApiClient: @unchecked Sendable {
private let session: URLSession
private let tokenStore: TokenStore
private let maxAttempts = 3
private let baseBackoffNanoseconds: UInt64 = 300_000_000
init(session: URLSession = .shared, tokenStore: TokenStore = DefaultTokenStore()) {
self.session = session
self.tokenStore = tokenStore
}
func send<T: Decodable & Sendable>(_ request: ApiRequest) async throws -> T {
// Hard client-side cutoff independent of whatever timeout logic
// lives inside the underlying transport (LCEssentials or plain
// URLSession). If that transport ever stalls without ever
// resolving no response, no error, nothing the UI must still
// get an answer so it can stop showing "nothing happened."
try await withTimeout(seconds: 20) { [self] in
#if canImport(LCEssentials)
try await sendWithLCEssentials(request)
#else
try await sendWithURLSession(request)
#endif
}
}
private func withTimeout<T: Sendable>(seconds: Double, operation: @escaping @Sendable () async throws -> T) async throws -> T {
try await withThrowingTaskGroup(of: T.self) { group in
group.addTask {
try await operation()
}
group.addTask {
try await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000))
throw NetworkError.timedOut
}
guard let result = try await group.next() else {
throw NetworkError.timedOut
}
group.cancelAll()
return result
}
}
}
extension ApiClient {
#if canImport(LCEssentials)
func sendWithLCEssentials<T: Decodable>(_ request: ApiRequest) async throws -> T {
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
var attempt = 1
while attempt <= maxAttempts {
do {
let responseString = try await Self.performLCERequest(
url: urlString,
params: params,
method: method,
headers: headers
)
guard let data = responseString.data(using: .utf8) else {
throw NetworkError.decodeError("Resposta nao UTF-8")
}
if let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let errorFlag = object["error"] as? Bool, errorFlag {
let message = object["message"] as? String ?? object["msg"] as? String
throw NetworkError.httpError(200, message ?? "Erro no servidor")
}
do {
return try JSONDecoder().decode(T.self, from: data)
} catch {
throw NetworkError.decodeError(sanitizedBody(data))
}
} catch {
let mapped = mapError(error)
guard shouldRetry(mapped), attempt < maxAttempts else {
throw mapped
}
try await Task.sleep(nanoseconds: backoff(for: attempt, error: mapped))
attempt += 1
}
}
throw NetworkError.invalidResponse
}
@MainActor
static func performLCERequest(
url: String,
params: Data?,
method: String,
headers: [String: String]
) async throws -> String {
let httpMethod = toHTTPMethod(method)
return try await API.shared.request(
url: url,
params: params,
method: httpMethod,
headers: headers,
jsonEncoding: true,
debug: true
)
}
static func toHTTPMethod(_ method: String) -> httpMethod {
switch method.uppercased() {
case "POST": return .post
case "PUT": return .put
case "DELETE": return .delete
case "PATCH": return .patch
default: return .get
}
}
func mapError(_ error: Error) -> NetworkError {
if let network = error as? NetworkError {
return network
}
if let decoding = error as? DecodingError {
return .decodeError(String(describing: decoding))
}
let nsError = error as NSError
let apiMessage = serverMessage(from: nsError)
let payload = serverPayload(from: nsError)
if isSessionExpiredPayload(code: payload?.code, message: payload?.message ?? apiMessage) {
return .unauthorized(payload?.message ?? apiMessage)
}
if nsError.domain == NSURLErrorDomain && nsError.code == NSURLErrorCancelled {
return .cancelled
}
printError(title: "httpReqError", msg: error.localizedDescription)
switch nsError.code {
case 401, 403:
return .unauthorized(apiMessage)
case 429:
return .rateLimited(nil)
case 400...599:
return .httpError(nsError.code, apiMessage)
default:
break
}
if nsError.domain == NSURLErrorDomain {
return .transportError(nsError.localizedDescription)
}
return .transportError(nsError.localizedDescription)
}
#endif
func sendWithURLSession<T: Decodable>(_ request: ApiRequest) async throws -> T {
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
for (key, value) in buildHeaders(for: request) {
urlRequest.setValue(value, forHTTPHeaderField: key)
}
var attempt = 1
while attempt <= maxAttempts {
do {
return try await perform(urlRequest, as: T.self)
} catch is CancellationError {
throw NetworkError.cancelled
} catch let error as NetworkError {
guard shouldRetry(error), attempt < maxAttempts else {
throw error
}
try await Task.sleep(nanoseconds: backoff(for: attempt, error: error))
attempt += 1
} catch {
let wrapped = NetworkError.transportError(error.localizedDescription)
guard attempt < maxAttempts else { throw wrapped }
try await Task.sleep(nanoseconds: backoff(for: attempt, error: wrapped))
attempt += 1
}
}
throw NetworkError.invalidResponse
}
func perform<T: Decodable>(_ request: URLRequest, as type: T.Type) async throws -> T {
let (data, response): (Data, URLResponse)
do {
(data, response) = try await session.data(for: request)
} catch {
if let urlError = error as? URLError, urlError.code == .cancelled {
throw NetworkError.cancelled
}
throw NetworkError.transportError(error.localizedDescription)
}
guard let http = response as? HTTPURLResponse else {
throw NetworkError.invalidResponse
}
let payload = serverPayload(from: data)
if isSessionExpiredPayload(code: payload?.code, message: payload?.message) {
throw NetworkError.unauthorized(payload?.message)
}
if let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let errorFlag = object["error"] as? Bool, errorFlag {
let message = object["message"] as? String ?? object["msg"] as? String
throw NetworkError.httpError(http.statusCode, message ?? "Erro no servidor")
}
if http.statusCode == 429 {
let retryAfter = Int(http.value(forHTTPHeaderField: "Retry-After") ?? "")
throw NetworkError.rateLimited(retryAfter)
}
if http.statusCode == 401 || http.statusCode == 403 {
throw NetworkError.unauthorized(serverMessage(from: data))
}
if !(200...299).contains(http.statusCode) {
throw NetworkError.httpError(http.statusCode, serverMessage(from: data))
}
do {
return try JSONDecoder().decode(type, from: data)
} catch {
throw NetworkError.decodeError(sanitizedBody(data))
}
}
func buildHeaders(for request: ApiRequest) -> [String: String] {
var headers: [String: String] = [
"Accept": "application/json",
"Content-Type": "application/json"
]
if let token = ApiConfig.token(for: request.module) {
headers["Atomenta-Token"] = token
}
if let customBearerToken = request.customBearerToken {
headers["Authorization"] = "Bearer \(customBearerToken)"
} else if request.requiresAuth, let jwt = tokenStore.jwt {
headers["Authorization"] = "Bearer \(jwt)"
}
return headers
}
func shouldRetry(_ error: NetworkError) -> Bool {
switch error {
case .rateLimited, .transportError:
return true
case .httpError(let statusCode, _):
return statusCode >= 500
case .invalidURL, .invalidResponse, .decodeError, .unauthorized, .cancelled, .timedOut:
return false
}
}
func backoff(for attempt: Int, error: NetworkError) -> UInt64 {
if case .rateLimited(let retryAfter) = error, let retryAfter {
return UInt64(retryAfter) * 1_000_000_000
}
let multiplier = UInt64(max(1, attempt))
return min(baseBackoffNanoseconds * multiplier, 2_000_000_000)
}
func serverMessage(from data: Data) -> String? {
let payload = serverPayload(from: data)
if let message = payload?.message, message.isEmpty == false {
return message
}
if let code = payload?.code, code.isEmpty == false {
return "Erro: \(code)"
}
// fallback if error true is present but without a classic structure
if let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let errorFlag = object["error"] as? Bool, errorFlag {
if let msg = object["message"] as? String ?? object["msg"] as? String {
return msg
}
}
return sanitizedBody(data)
}
func serverPayload(from data: Data) -> ApiErrorDescriptor? {
if let envelope = try? JSONDecoder().decode(ApiEnvelope<EmptyResult>.self, from: data) {
return ApiErrorDescriptor(code: envelope.code, message: envelope.message)
}
if let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any] {
let errorFlag = object["error"] as? Bool ?? false
let code = object["code"] as? String
let message = object["message"] as? String ?? object["msg"] as? String ?? object["error_description"] as? String
if errorFlag || code != nil || message != nil {
return ApiErrorDescriptor(code: code, message: message)
}
}
return nil
}
func isSessionExpiredPayload(code: String?, message: String?) -> Bool {
let normalizedCode = (code ?? "").lowercased()
let normalizedMessage = (message ?? "").lowercased()
if normalizedCode.contains("auth") || normalizedCode.contains("token") || normalizedCode.contains("unauthorized") {
return true
}
if normalizedMessage.contains("token") && (normalizedMessage.contains("expir") || normalizedMessage.contains("invalid") || normalizedMessage.contains("sess")) {
return true
}
return false
}
#if canImport(LCEssentials)
func serverMessage(from error: NSError) -> String? {
for key in [NSLocalizedFailureReasonErrorKey, "body", "responseBody", "data", "message"] {
if let value = error.userInfo[key] as? String,
!value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
if let data = value.data(using: .utf8),
let parsed = serverMessage(from: data),
!parsed.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
let t = parsed.trimmingCharacters(in: .whitespacesAndNewlines)
if t.hasPrefix("{") { continue }
if let safe = sanitizedMessage(t) { return safe }
}
let t = value.trimmingCharacters(in: .whitespacesAndNewlines)
if !t.hasPrefix("{"), let safe = sanitizedMessage(t) { return safe }
} else if let valueData = error.userInfo[key] as? Data {
if let parsed = serverMessage(from: valueData) {
let t = parsed.trimmingCharacters(in: .whitespacesAndNewlines)
if !t.hasPrefix("{"), let safe = sanitizedMessage(t) { return safe }
}
}
}
for (_, value) in error.userInfo {
if let str = value as? String,
str.trimmingCharacters(in: .whitespacesAndNewlines).hasPrefix("{"),
let data = str.data(using: .utf8),
let parsed = serverMessage(from: data) {
let t = parsed.trimmingCharacters(in: .whitespacesAndNewlines)
if !t.hasPrefix("{"), let safe = sanitizedMessage(t) { return safe }
}
}
if let description = error.userInfo[NSLocalizedDescriptionKey] as? String,
!description.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty,
!description.lowercased().contains("nsurlerrordomain"),
let safe = sanitizedMessage(description) {
return safe
}
return nil
}
func serverPayload(from error: NSError) -> ApiErrorDescriptor? {
for key in [NSLocalizedFailureReasonErrorKey, "body", "responseBody", "data"] {
if let value = error.userInfo[key] as? String,
let data = value.data(using: .utf8),
let payload = serverPayload(from: data) {
return payload
} else if let valueData = error.userInfo[key] as? Data,
let payload = serverPayload(from: valueData) {
return payload
}
}
for (_, value) in error.userInfo {
if let str = value as? String,
str.trimmingCharacters(in: .whitespacesAndNewlines).hasPrefix("{"),
let data = str.data(using: .utf8),
let payload = serverPayload(from: data) {
return payload
}
}
return nil
}
#endif
func sanitizedBody(_ data: Data) -> String? {
guard let raw = String(data: data, encoding: .utf8) else { return nil }
return sanitizedMessage(raw)
}
func sanitizedMessage(_ raw: String) -> String? {
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
guard trimmed.isEmpty == false else { return nil }
// Drop data URLs (base64 images)
if trimmed.lowercased().hasPrefix("data:image") { return "Erro ao processar imagem." }
// Drop fields containing base64,
if trimmed.contains("base64,") { return "Resposta do servidor inválida." }
// Truncate long strings (raw JSON bodies, etc.)
if trimmed.count > 300 {
return String(trimmed.prefix(300)) + ""
}
return trimmed
}
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)
if !query.isEmpty {
components.queryItems = query
}
guard let url = components.url else { throw NetworkError.invalidURL }
return url
}
}

View File

@@ -0,0 +1,71 @@
import Foundation
enum ApiModule: Sendable {
case app
case customer
case store
case resource
case none
}
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")!
}
static var featureControlBffURL: URL {
let raw = ProcessInfo.processInfo.environment["FEATURE_CONTROL_BFF_URL"] ?? "http://localhost:8787"
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)
return clean.isEmpty ? "production" : clean
}
// Tokens provided by backend modules
static let storeToken = "550e8400-e29b-41d4-a716-446655440008"
static let customerToken = "550e8400-e29b-41d4-a716-44665544000a"
static let resourceToken = "550e8400-e29b-41d4-a716-446655440009"
static func token(for module: ApiModule) -> String? {
switch module {
case .store: return storeToken
case .customer: return customerToken
case .resource: return resourceToken
case .app, .none: return nil
}
}
}
enum LegalDocument: Sendable {
case terms
case privacyPolicy
private var path: String {
switch self {
case .terms: return "api/public/pedi-foods-customer/terms"
case .privacyPolicy: return "api/public/pedi-foods-customer/privacy-policy"
}
}
var url: URL {
ApiConfig.baseURL.appendingPathComponent(path)
}
}

View File

@@ -0,0 +1,122 @@
import Foundation
struct PublicCategory: Decodable {
let id: String
let name: String
let icon: String?
}
struct CustomerProfileUpdatePayload: Encodable {
let addressBook: [CustomerAddressPayload]
enum CodingKeys: String, CodingKey {
case addressBook = "address_book"
}
}
struct CustomerIdentityUpdatePayload: Encodable {
let name: String?
let email: String?
let phoneNumber: String?
let profilePicture: String?
enum CodingKeys: String, CodingKey {
case name
case email
case phoneNumber
case profilePicture
}
func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encodeIfPresent(name, forKey: .name)
try container.encodeIfPresent(email, forKey: .email)
try container.encodeIfPresent(phoneNumber, forKey: .phoneNumber)
if let profilePicture, profilePicture.isEmpty == false {
try container.encode(profilePicture, forKey: .profilePicture)
}
}
}
/// `POST /api/customer/:id` partial update, sibling of `CustomerProfileUpdatePayload`.
/// See docs/api/push-notifications-integration-guide.md §2b.
struct CustomerNotificationsUpdatePayload: Encodable {
let notificationsEnabled: Bool
}
/// `POST /api/customer/:id` biometric-login preference. Persistence only for
/// now; the actual Face ID/Touch ID unlock flow is a separate, later plan.
struct CustomerFaceIdUpdatePayload: Encodable {
let faceIdEnabled: Bool
}
/// `PUT /api/customer/:id/push-token` see docs/api/push-notifications-integration-guide.md §2.
struct CustomerPushTokenPayload: Encodable {
let pushToken: String
let deviceId: String
let deviceOS: String
}
/// `PUT /api/customer/:id/attributes` wholesale replace, not a merge.
/// See docs/api/push-notifications-integration-guide.md §2a.
struct CustomerAttributesUpdatePayload: Encodable {
let appVersion: String?
let attributes: [String: String]?
func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encodeIfPresent(appVersion, forKey: .appVersion)
try container.encodeIfPresent(attributes, forKey: .attributes)
}
enum CodingKeys: String, CodingKey {
case appVersion
case attributes
}
}
/// `POST /api/customer/:id/push-campaigns/opened` see
/// docs/api/push-notifications-integration-guide.md §6a.
struct PushCampaignOpenedPayload: Encodable {
let campaignId: String
}
struct PushCampaignOpenedResult: Decodable {
let recorded: Bool
}
struct CustomerAddressPayload: Encodable {
let label: String?
let address: String?
let number: String?
let complement: String?
let neighborhood: String?
let city: String?
let state: String?
let zipCode: String?
let latLong: [Double]?
enum CodingKeys: String, CodingKey {
case label
case address
case number
case complement
case neighborhood
case city
case state
case zipCode
case latLong = "lat_long"
}
init(from address: CustomerAddress) {
self.label = address.label
self.address = address.address
self.number = address.number
self.complement = address.complement
self.neighborhood = address.neighborhood
self.city = address.city
self.state = address.state
self.zipCode = address.zipCode
self.latLong = address.latLong
}
}

View File

@@ -0,0 +1,6 @@
import Foundation
struct CustomerFavoritesMutationResult: Decodable {
let favorites: [String]
let store: StoreSummary?
}

View File

@@ -0,0 +1,552 @@
import Foundation
// MARK: - DTOs
struct EmptyResult: Decodable {}
struct ProfilePatchEnvelope: Decodable {
let error: Bool
let code: String?
let message: String?
let profilePictureUrl: String?
}
struct RegistrationResult: Decodable {
let id: String?
let name: String?
let email: String?
}
struct LoginResult: Decodable {
let token: String
let customer: CustomerProfile?
}
struct CustomerProfile: Decodable {
let id: String
let name: String
let email: String
let phoneNumber: String?
let profilePicture: String?
let favorites: [String]?
let addressBook: [CustomerAddress]?
let notificationsEnabled: Bool?
let faceIdEnabled: Bool?
enum CodingKeys: String, CodingKey {
case id
case name
case email
case phoneNumber
case profilePicture
case favorites
case addressBook = "address_book"
case notificationsEnabled
case faceIdEnabled
}
}
struct CustomerAddress: Decodable {
let id: String?
let label: String?
let address: String?
let number: String?
let complement: String?
let neighborhood: String?
let city: String?
let state: String?
let zipCode: String?
let latLong: [Double]?
let isDefault: Bool?
init(id: String?, label: String?, address: String?, number: String?,
complement: String?, neighborhood: String?, city: String?,
state: String?, zipCode: String?, latLong: [Double]?, isDefault: Bool?) {
self.id = id; self.label = label; self.address = address
self.number = number; self.complement = complement
self.neighborhood = neighborhood; self.city = city
self.state = state; self.zipCode = zipCode
self.latLong = latLong; self.isDefault = isDefault
}
enum CodingKeys: String, CodingKey {
case id, label, address, number, complement
case neighborhood, city, state, zipCode
case latLong = "lat_long"
case isDefault
}
}
struct StoreSummary: Decodable {
let id: String
let name: String
let logo: String?
let cover: String?
let category: String?
let rating: Double?
let reviewsCount: Int?
let positiveReviews: Int?
let deliveryTime: String?
let deliveryFee: Double?
let distance: Double?
let isOpen: Bool?
let statusLabel: String?
enum CodingKeys: String, CodingKey {
case id
case name
case logo
case cover
case category
case rating
case reviewsCount
case totalReviews
case reviews
case positiveReviews
case positive_reviews
case deliveryTime
case deliveryFee
case distance
case isOpen
case statusLabel
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
id = (try? container.decode(String.self, forKey: .id)) ?? UUID().uuidString
name = (try? container.decode(String.self, forKey: .name)) ?? "Loja"
logo = try? container.decode(String.self, forKey: .logo)
cover = try? container.decode(String.self, forKey: .cover)
category = try? container.decode(String.self, forKey: .category)
rating = ApiService.decodeFlexibleDouble(from: container, keys: [.rating])
reviewsCount = ApiService.decodeFlexibleInt(from: container, keys: [.reviewsCount, .totalReviews, .reviews])
positiveReviews = ApiService.decodeFlexibleInt(from: container, keys: [.positiveReviews, .positive_reviews])
deliveryTime = try? container.decode(String.self, forKey: .deliveryTime)
deliveryFee = ApiService.decodeFlexibleDouble(from: container, keys: [.deliveryFee])
distance = ApiService.decodeFlexibleDouble(from: container, keys: [.distance])
isOpen = try? container.decode(Bool.self, forKey: .isOpen)
statusLabel = try? container.decode(String.self, forKey: .statusLabel)
}
}
struct StoreInfoResult: Decodable {
let isOpen: Bool?
let statusLabel: String?
let fantasyName: String?
let phone: String?
let whatsapp: String?
let logo: String?
let cover: String?
let deliveryTime: String?
let minOrder: Double?
let address: StoreAddressInfo?
let paymentMethods: StorePaymentMethodsInfo?
enum CodingKeys: String, CodingKey {
case isOpen
case statusLabel
case fantasyName
case phone
case whatsapp
case logo
case cover
case deliveryTime
case minOrder
case address
case paymentMethods
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
isOpen = try? container.decode(Bool.self, forKey: .isOpen)
statusLabel = try? container.decode(String.self, forKey: .statusLabel)
fantasyName = try? container.decode(String.self, forKey: .fantasyName)
phone = ApiService.decodeFlexibleString(from: container, keys: [.phone])
whatsapp = ApiService.decodeFlexibleString(from: container, keys: [.whatsapp, .phone])
logo = try? container.decode(String.self, forKey: .logo)
cover = try? container.decode(String.self, forKey: .cover)
deliveryTime = try? container.decode(String.self, forKey: .deliveryTime)
minOrder = ApiService.decodeFlexibleDouble(from: container, keys: [.minOrder])
address = try? container.decode(StoreAddressInfo.self, forKey: .address)
paymentMethods = try? container.decode(StorePaymentMethodsInfo.self, forKey: .paymentMethods)
}
}
struct StoreAddressInfo: Decodable {
let street: String?
let number: String?
let neighborhood: String?
let city: String?
let state: String?
let zipCode: String?
let latitude: Double?
let longitude: Double?
enum CodingKeys: String, CodingKey {
case street
case number
case neighborhood
case city
case state
case zipCode
case zipcode
case latitude
case longitude
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
street = try? container.decode(String.self, forKey: .street)
number = try? container.decode(String.self, forKey: .number)
neighborhood = try? container.decode(String.self, forKey: .neighborhood)
city = try? container.decode(String.self, forKey: .city)
state = try? container.decode(String.self, forKey: .state)
zipCode = (try? container.decode(String.self, forKey: .zipCode))
?? (try? container.decode(String.self, forKey: .zipcode))
latitude = ApiService.decodeFlexibleDouble(from: container, keys: [.latitude])
longitude = ApiService.decodeFlexibleDouble(from: container, keys: [.longitude])
}
}
struct StorePaymentMethodsInfo: Decodable {
let paymentOnDelivery: Bool?
let paymentOnPickup: Bool?
let acceptPix: Bool?
let acceptCash: Bool?
let acceptCreditCard: Bool?
let acceptDebitCard: Bool?
let acceptCreditVisa: Bool?
let acceptCreditMaster: Bool?
let acceptCreditElo: Bool?
let acceptCreditAmex: Bool?
let acceptCreditHipercard: Bool?
let acceptDebitVisa: Bool?
let acceptDebitMaster: Bool?
let acceptDebitElo: Bool?
let acceptVoucherAlelo: Bool?
let acceptVoucherSodexo: Bool?
let acceptVoucherTicket: Bool?
let acceptVoucherVR: Bool?
enum CodingKeys: String, CodingKey {
case paymentOnDelivery
case paymentOnPickup
case acceptPix
case acceptCash
case acceptCreditCard
case acceptDebitCard
case acceptCreditVisa
case acceptCreditMaster
case acceptCreditElo
case acceptCreditAmex
case acceptCreditHipercard
case acceptDebitVisa
case acceptDebitMaster
case acceptDebitElo
case acceptVoucherAlelo
case acceptVoucherSodexo
case acceptVoucherTicket
case acceptVoucherVR
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
paymentOnDelivery = try? container.decode(Bool.self, forKey: .paymentOnDelivery)
paymentOnPickup = try? container.decode(Bool.self, forKey: .paymentOnPickup)
acceptPix = try? container.decode(Bool.self, forKey: .acceptPix)
acceptCash = try? container.decode(Bool.self, forKey: .acceptCash)
acceptCreditCard = try? container.decode(Bool.self, forKey: .acceptCreditCard)
acceptDebitCard = try? container.decode(Bool.self, forKey: .acceptDebitCard)
acceptCreditVisa = try? container.decode(Bool.self, forKey: .acceptCreditVisa)
acceptCreditMaster = try? container.decode(Bool.self, forKey: .acceptCreditMaster)
acceptCreditElo = try? container.decode(Bool.self, forKey: .acceptCreditElo)
acceptCreditAmex = try? container.decode(Bool.self, forKey: .acceptCreditAmex)
acceptCreditHipercard = try? container.decode(Bool.self, forKey: .acceptCreditHipercard)
acceptDebitVisa = try? container.decode(Bool.self, forKey: .acceptDebitVisa)
acceptDebitMaster = try? container.decode(Bool.self, forKey: .acceptDebitMaster)
acceptDebitElo = try? container.decode(Bool.self, forKey: .acceptDebitElo)
acceptVoucherAlelo = try? container.decode(Bool.self, forKey: .acceptVoucherAlelo)
acceptVoucherSodexo = try? container.decode(Bool.self, forKey: .acceptVoucherSodexo)
acceptVoucherTicket = try? container.decode(Bool.self, forKey: .acceptVoucherTicket)
acceptVoucherVR = try? container.decode(Bool.self, forKey: .acceptVoucherVR)
}
var hasAnyCreditCard: Bool {
(acceptCreditCard ?? false)
|| (acceptCreditVisa ?? false)
|| (acceptCreditMaster ?? false)
|| (acceptCreditElo ?? false)
|| (acceptCreditAmex ?? false)
|| (acceptCreditHipercard ?? false)
}
var hasAnyDebitCard: Bool {
(acceptDebitCard ?? false)
|| (acceptDebitVisa ?? false)
|| (acceptDebitMaster ?? false)
|| (acceptDebitElo ?? false)
}
var hasAnyVoucher: Bool {
(acceptVoucherAlelo ?? false)
|| (acceptVoucherSodexo ?? false)
|| (acceptVoucherTicket ?? false)
|| (acceptVoucherVR ?? false)
}
}
struct StoreCatalogCategory: Decodable {
let id: String
let name: String
let isPizzaCategory: Bool
let pizzaConfig: StorePizzaConfig?
let products: [StoreCatalogProduct]
enum CodingKeys: String, CodingKey {
case id
case name
case isPizzaCategory
case pizzaConfig
case products
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
id = (try? container.decode(String.self, forKey: .id)) ?? UUID().uuidString
name = (try? container.decode(String.self, forKey: .name)) ?? "Categoria"
isPizzaCategory = (try? container.decode(Bool.self, forKey: .isPizzaCategory)) ?? false
pizzaConfig = try? container.decode(StorePizzaConfig.self, forKey: .pizzaConfig)
products = (try? container.decode([StoreCatalogProduct].self, forKey: .products)) ?? []
}
}
struct StoreCatalogProduct: Decodable, Identifiable {
let id: String
let type: String?
let name: String
let description: String?
let image: String?
let price: Double?
let originalPrice: Double?
let pizzaPrices: [String: Double]
let addonGroups: [StoreAddonGroup]
enum CodingKeys: String, CodingKey {
case id
case type
case name
case description
case desc
case image
case cover
case photo
case price
case originalPrice
case oldPrice
case pizzaPrices
case addonGroups
case addons
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
id = (try? container.decode(String.self, forKey: .id)) ?? UUID().uuidString
type = try? container.decode(String.self, forKey: .type)
name = (try? container.decode(String.self, forKey: .name)) ?? "Produto"
description = (try? container.decode(String.self, forKey: .description)) ?? (try? container.decode(String.self, forKey: .desc))
image = (try? container.decode(String.self, forKey: .image))
?? (try? container.decode(String.self, forKey: .cover))
?? (try? container.decode(String.self, forKey: .photo))
price = ApiService.decodeFlexibleDouble(from: container, keys: [.price])
originalPrice = ApiService.decodeFlexibleDouble(from: container, keys: [.originalPrice, .oldPrice])
pizzaPrices = StoreCatalogProduct.decodePizzaPrices(container: container)
addonGroups = (try? container.decode([StoreAddonGroup].self, forKey: .addonGroups))
?? (try? container.decode([StoreAddonGroup].self, forKey: .addons))
?? []
}
private static func decodePizzaPrices(container: KeyedDecodingContainer<CodingKeys>) -> [String: Double] {
if let direct = try? container.decode([String: Double].self, forKey: .pizzaPrices) {
return direct
}
if let asInt = try? container.decode([String: Int].self, forKey: .pizzaPrices) {
return asInt.mapValues { Double($0) }
}
if let asString = try? container.decode([String: String].self, forKey: .pizzaPrices) {
var parsed: [String: Double] = [:]
for (key, value) in asString {
let normalized = value
.trimmingCharacters(in: .whitespacesAndNewlines)
.replacingOccurrences(of: ",", with: ".")
if let number = Double(normalized) {
parsed[key] = number
}
}
return parsed
}
return [:]
}
}
struct StoreAddonGroup: Decodable, Identifiable {
let id: String
let name: String
let minSelectors: Int?
let maxSelectors: Int?
let items: [StoreAddonItem]
enum CodingKeys: String, CodingKey {
case id
case name
case minSelectors
case maxSelectors
case items
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
id = (try? container.decode(String.self, forKey: .id)) ?? UUID().uuidString
name = (try? container.decode(String.self, forKey: .name)) ?? "Adicionais"
minSelectors = try? container.decode(Int.self, forKey: .minSelectors)
maxSelectors = try? container.decode(Int.self, forKey: .maxSelectors)
items = (try? container.decode([StoreAddonItem].self, forKey: .items)) ?? []
}
}
struct StoreAddonItem: Decodable, Identifiable {
let id: String
let name: String
let price: Double?
enum CodingKeys: String, CodingKey {
case id
case name
case price
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
id = (try? container.decode(String.self, forKey: .id)) ?? UUID().uuidString
name = (try? container.decode(String.self, forKey: .name)) ?? "Item"
price = ApiService.decodeFlexibleDouble(from: container, keys: [.price])
}
}
struct CepLookupResult: Decodable {
let zipCode: String?
let street: String?
let neighborhood: String?
let city: String?
let state: String?
let complement: String?
let latitude: Double?
let longitude: Double?
enum CodingKeys: String, CodingKey {
case zipCode
case cep
case zip
case normalized
case raw
case street
case logradouro
case address
case neighborhood
case bairro
case district
case city
case cidade
case localidade
case state
case estado
case uf
case complement
case complemento
case latitude
case lat
case longitude
case lng
}
enum NormalizedKeys: String, CodingKey {
case cep
case logradouro
case bairro
case cidade
case uf
case latitude
case longitude
}
enum RawKeys: String, CodingKey {
case cep
case address
case district
case city
case state
case lat
case lng
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
let normalizedContainer = try? container.nestedContainer(keyedBy: NormalizedKeys.self, forKey: .normalized)
let rawContainer = try? container.nestedContainer(keyedBy: RawKeys.self, forKey: .raw)
let directZip = CepLookupResult.decodeString(from: container, keys: [.zipCode, .cep, .zip])
let directStreet = CepLookupResult.decodeString(from: container, keys: [.street, .logradouro, .address])
let directNeighborhood = CepLookupResult.decodeString(from: container, keys: [.neighborhood, .bairro, .district])
let directCity = CepLookupResult.decodeString(from: container, keys: [.city, .cidade, .localidade])
let directState = CepLookupResult.decodeString(from: container, keys: [.state, .estado, .uf])
let directComplement = CepLookupResult.decodeString(from: container, keys: [.complement, .complemento])
let directLatitude = CepLookupResult.decodeFlexibleDouble(from: container, keys: [.latitude, .lat])
let directLongitude = CepLookupResult.decodeFlexibleDouble(from: container, keys: [.longitude, .lng])
let normalizedZip = normalizedContainer.flatMap { CepLookupResult.decodeString(from: $0, keys: [.cep]) }
let normalizedStreet = normalizedContainer.flatMap { CepLookupResult.decodeString(from: $0, keys: [.logradouro]) }
let normalizedNeighborhood = normalizedContainer.flatMap { CepLookupResult.decodeString(from: $0, keys: [.bairro]) }
let normalizedCity = normalizedContainer.flatMap { CepLookupResult.decodeString(from: $0, keys: [.cidade]) }
let normalizedState = normalizedContainer.flatMap { CepLookupResult.decodeString(from: $0, keys: [.uf]) }
let normalizedLatitude = normalizedContainer.flatMap { CepLookupResult.decodeFlexibleDouble(from: $0, keys: [.latitude]) }
let normalizedLongitude = normalizedContainer.flatMap { CepLookupResult.decodeFlexibleDouble(from: $0, keys: [.longitude]) }
let rawZip = rawContainer.flatMap { CepLookupResult.decodeString(from: $0, keys: [.cep]) }
let rawStreet = rawContainer.flatMap { CepLookupResult.decodeString(from: $0, keys: [.address]) }
let rawNeighborhood = rawContainer.flatMap { CepLookupResult.decodeString(from: $0, keys: [.district]) }
let rawCity = rawContainer.flatMap { CepLookupResult.decodeString(from: $0, keys: [.city]) }
let rawState = rawContainer.flatMap { CepLookupResult.decodeString(from: $0, keys: [.state]) }
let rawLatitude = rawContainer.flatMap { CepLookupResult.decodeFlexibleDouble(from: $0, keys: [.lat]) }
let rawLongitude = rawContainer.flatMap { CepLookupResult.decodeFlexibleDouble(from: $0, keys: [.lng]) }
zipCode = directZip ?? normalizedZip ?? rawZip
street = directStreet ?? normalizedStreet ?? rawStreet
neighborhood = directNeighborhood ?? normalizedNeighborhood ?? rawNeighborhood
city = directCity ?? normalizedCity ?? rawCity
state = directState ?? normalizedState ?? rawState
complement = directComplement
latitude = directLatitude ?? normalizedLatitude ?? rawLatitude
longitude = directLongitude ?? normalizedLongitude ?? rawLongitude
}
private static func decodeString<K: CodingKey>(from container: KeyedDecodingContainer<K>, keys: [K]) -> String? {
for key in keys {
if let value = try? container.decode(String.self, forKey: key) {
return value
}
}
return nil
}
private static func decodeFlexibleDouble<K: CodingKey>(from container: KeyedDecodingContainer<K>, keys: [K]) -> Double? {
for key in keys {
if let value = try? container.decode(Double.self, forKey: key) {
return value
}
if let valueAsString = try? container.decode(String.self, forKey: key),
let parsed = Double(valueAsString.replacingOccurrences(of: ",", with: ".")) {
return parsed
}
}
return nil
}
}

View File

@@ -0,0 +1,196 @@
import Foundation
struct CreateOrderPayload: Encodable {
let customer: CreateOrderCustomerPayload
let items: [CreateOrderItemPayload]
let total: Double
let paymentMethod: String
let deliveryType: String
let address: CreateOrderAddressPayload?
// Cartão salvo
let savedCardId: String?
// Novo cartão (checkout transparente)
let clientCpfCnpj: String?
let creditCard: CreditCardOrderPayload?
let creditCardHolderInfo: SaveCardHolderInfoPayload?
}
struct CreateOrderCustomerPayload: Encodable {
let name: String
let phone: String
let email: String
let asaasId: String?
}
struct CreateOrderItemPayload: Codable {
let productId: String
let name: String
let qty: Int
let price: Double
let addons: [CreateOrderAddonPayload]
let choices: [String]?
}
struct CreateOrderAddonPayload: Codable {
let addonId: String
let name: String
let qty: Int
let price: Double
}
struct CreateOrderAddressPayload: Encodable {
let street: String
let number: String
let neighborhood: String
let city: String?
let state: String?
let zip: String?
let complement: String?
}
struct CreateOrderResult: Decodable {
let id: String?
let shortId: String?
let status: String?
let paymentStatus: String?
let paymentConfirmed: Bool?
let paymentMethod: String?
let paymentPayload: CreateOrderPaymentPayload?
let payment: CreateOrderPaymentInfo?
enum CodingKeys: String, CodingKey {
case id
case shortId
case status
case paymentStatus
case paymentConfirmed
case paymentMethod
case paymentPayload
case payment
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
id = ApiService.decodeFlexibleString(from: container, keys: [.id])
shortId = ApiService.decodeFlexibleString(from: container, keys: [.shortId])
status = ApiService.decodeFlexibleString(from: container, keys: [.status])
paymentStatus = ApiService.decodeFlexibleString(from: container, keys: [.paymentStatus])
paymentConfirmed = try? container.decode(Bool.self, forKey: .paymentConfirmed)
paymentMethod = ApiService.decodeFlexibleString(from: container, keys: [.paymentMethod])
payment = try? container.decode(CreateOrderPaymentInfo.self, forKey: .payment)
if let objectPayload = try? container.decode(CreateOrderPaymentPayload.self, forKey: .paymentPayload) {
paymentPayload = objectPayload
} else if let stringPayload = try? container.decode(String.self, forKey: .paymentPayload) {
paymentPayload = CreateOrderPaymentPayload(
copyPaste: stringPayload,
qrCodeImage: nil,
expirationDate: nil
)
} else {
paymentPayload = nil
}
}
}
struct CreateOrderPaymentInfo: Codable {
let method: String?
let status: String?
let pix: CreateOrderPaymentPayload?
}
struct CreateOrderPaymentPayload: Codable {
let copyPaste: String?
let qrCodeImage: String?
let expirationDate: String?
enum CodingKeys: String, CodingKey {
case copyPaste
case payload
case qrCodeImage
case encodedImage
case expirationDate
}
init(copyPaste: String?, qrCodeImage: String?, expirationDate: String?) {
self.copyPaste = copyPaste
self.qrCodeImage = qrCodeImage
self.expirationDate = expirationDate
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
copyPaste = (try? container.decode(String.self, forKey: .copyPaste))
?? (try? container.decode(String.self, forKey: .payload))
qrCodeImage = (try? container.decode(String.self, forKey: .qrCodeImage))
?? (try? container.decode(String.self, forKey: .encodedImage))
expirationDate = try? container.decode(String.self, forKey: .expirationDate)
}
func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encodeIfPresent(copyPaste, forKey: .copyPaste)
try container.encodeIfPresent(qrCodeImage, forKey: .qrCodeImage)
try container.encodeIfPresent(expirationDate, forKey: .expirationDate)
}
}
struct ValidateDeliveryAddressPayload: Encodable {
let address: ValidateDeliveryAddressDataPayload
}
struct ValidateDeliveryAddressDataPayload: Encodable {
let street: String?
let number: String?
let neighborhood: String?
let city: String?
let state: String?
let zip: String?
let lat: Double?
let lng: Double?
}
struct ValidateDeliveryAddressResult: Decodable {
let deliveryAllowed: Bool?
let reasonCode: String?
let reasonMessage: String?
let deliveryMode: String?
let distance: Double?
let deliveryFee: Double?
let deliveryTime: String?
let sameCity: Bool?
enum CodingKeys: String, CodingKey {
case deliveryAllowed
case delivery_allowed
case reasonCode
case reason_code
case reasonMessage
case reason_message
case deliveryMode
case delivery_mode
case distance
case deliveryFee
case delivery_fee
case fee
case taxa
case deliveryTime
case delivery_time
case sameCity
case same_city
}
init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
deliveryAllowed = (try? c.decode(Bool.self, forKey: .deliveryAllowed))
?? (try? c.decode(Bool.self, forKey: .delivery_allowed))
reasonCode = ApiService.decodeFlexibleString(from: c, keys: [.reasonCode, .reason_code])
reasonMessage = ApiService.decodeFlexibleString(from: c, keys: [.reasonMessage, .reason_message])
deliveryMode = ApiService.decodeFlexibleString(from: c, keys: [.deliveryMode, .delivery_mode])
distance = ApiService.decodeFlexibleDouble(from: c, keys: [.distance])
deliveryFee = ApiService.decodeFlexibleDouble(from: c, keys: [.deliveryFee, .delivery_fee, .fee, .taxa])
deliveryTime = ApiService.decodeFlexibleString(from: c, keys: [.deliveryTime, .delivery_time])
sameCity = (try? c.decode(Bool.self, forKey: .sameCity))
?? (try? c.decode(Bool.self, forKey: .same_city))
}
}

View File

@@ -0,0 +1,715 @@
import Foundation
struct AppOrderSummary: Decodable, Identifiable {
let id: String
let orderId: String?
let realId: String?
let storeId: String?
let shortId: String?
let total: Double?
let status: String?
let statusDetailed: String?
let statusLabel: String?
let nextAction: String?
let paymentStatus: String?
let paymentMethod: String?
let deliveryType: String?
let storeName: String?
let storePhone: String?
let storeLogoURL: String?
let createdAt: String?
let updatedAt: String?
enum CodingKeys: String, CodingKey {
case id
case orderId
case realId
case storeId
case store_id
case shortId
case total
case status
case statusDetailed
case statusLabel
case nextAction
case paymentStatus
case paymentMethod
case deliveryType
case storeName
case storePhone
case storeLogo
case store_logo
case logo
case storeImage
case store_image
case storeImageUrl
case logoUrl
case date
case createdAt
case updatedAt
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
orderId = ApiService.decodeFlexibleString(from: container, keys: [.orderId])
id = ApiService.decodeFlexibleString(from: container, keys: [.orderId, .realId, .id]) ?? UUID().uuidString
realId = ApiService.decodeFlexibleString(from: container, keys: [.realId])
storeId = ApiService.decodeFlexibleString(from: container, keys: [.storeId, .store_id])
shortId = ApiService.decodeFlexibleString(from: container, keys: [.shortId])
total = ApiService.decodeFlexibleDouble(from: container, keys: [.total])
status = ApiService.decodeFlexibleString(from: container, keys: [.status])
statusDetailed = ApiService.decodeFlexibleString(from: container, keys: [.statusDetailed])
statusLabel = ApiService.decodeFlexibleString(from: container, keys: [.statusLabel])
nextAction = ApiService.decodeFlexibleString(from: container, keys: [.nextAction])
paymentStatus = ApiService.decodeFlexibleString(from: container, keys: [.paymentStatus])
paymentMethod = ApiService.decodeFlexibleString(from: container, keys: [.paymentMethod])
deliveryType = ApiService.decodeFlexibleString(from: container, keys: [.deliveryType])
storeName = ApiService.decodeFlexibleString(from: container, keys: [.storeName])
storePhone = ApiService.decodeFlexibleString(from: container, keys: [.storePhone])
storeLogoURL = ApiService.decodeFlexibleString(from: container, keys: [.storeLogo, .store_logo, .logo, .storeImage, .store_image, .storeImageUrl, .logoUrl])
let fallbackDate = ApiService.decodeFlexibleString(from: container, keys: [.date])
createdAt = ApiService.decodeFlexibleString(from: container, keys: [.createdAt]) ?? fallbackDate
updatedAt = ApiService.decodeFlexibleString(from: container, keys: [.updatedAt]) ?? fallbackDate
}
}
struct PublicOrderResult: Codable, Identifiable {
let id: String
let shortId: String?
let realId: String?
let storeId: String?
let status: String?
let paymentStatus: String?
let paymentConfirmed: Bool?
let paymentMethod: String?
let paymentMethodCode: String?
let paymentPayload: CreateOrderPaymentPayload?
let payment: CreateOrderPaymentInfo?
let nextAction: String?
let deliveryType: String?
let deliveryTypeLabel: String?
let subtotal: Double?
let deliveryFee: Double?
let discount: Double?
let total: Double?
let storeName: String?
let storePhone: String?
let storeLogoURL: String?
let createdAt: String?
let updatedAt: String?
let otp: String?
let customerOtp: String?
let confirmOtp: String?
let cancellationReason: String?
let fullAddress: String?
let deliveryAddress: PublicOrderDeliveryAddress?
let review: PublicOrderReview?
let items: [PublicOrderItem]
let timeline: [PublicOrderTimelineEvent]
enum CodingKeys: String, CodingKey {
case id
case shortId
case realId
case storeId
case store_id
case status
case paymentStatus
case paymentConfirmed
case paymentMethod
case paymentMethodCode
case paymentPayload
case payment
case nextAction
case deliveryType
case deliveryTypeLabel
case subtotal
case subTotal
case itemsTotal
case deliveryFee
case delivery_fee
case fee
case discount
case desconto
case couponDiscount
case total
case storeName
case storePhone
case storeLogo
case store_logo
case logo
case storeImage
case store_image
case storeImageUrl
case logoUrl
case createdAt
case updatedAt
case otp
case customerOtp
case confirmOtp
case cancellationReason
case fullAddress
case address
case deliveryAddress
case delivery_address
case customerAddress
case customer_address
case review
case orderReview
case items
case timeline
case history
case orderedAt
}
init(
id: String,
shortId: String? = nil,
realId: String? = nil,
storeId: String? = nil,
status: String? = nil,
paymentStatus: String? = nil,
paymentConfirmed: Bool? = nil,
paymentMethod: String? = nil,
paymentMethodCode: String? = nil,
paymentPayload: CreateOrderPaymentPayload? = nil,
payment: CreateOrderPaymentInfo? = nil,
nextAction: String? = nil,
deliveryType: String? = nil,
deliveryTypeLabel: String? = nil,
subtotal: Double? = nil,
deliveryFee: Double? = nil,
discount: Double? = nil,
total: Double? = nil,
storeName: String? = nil,
storePhone: String? = nil,
storeLogoURL: String? = nil,
createdAt: String? = nil,
updatedAt: String? = nil,
otp: String? = nil,
customerOtp: String? = nil,
confirmOtp: String? = nil,
cancellationReason: String? = nil,
fullAddress: String? = nil,
deliveryAddress: PublicOrderDeliveryAddress? = nil,
review: PublicOrderReview? = nil,
items: [PublicOrderItem] = [],
timeline: [PublicOrderTimelineEvent] = []
) {
self.id = id
self.shortId = shortId
self.realId = realId
self.storeId = storeId
self.status = status
self.paymentStatus = paymentStatus
self.paymentConfirmed = paymentConfirmed
self.paymentMethod = paymentMethod
self.paymentMethodCode = paymentMethodCode
self.paymentPayload = paymentPayload
self.payment = payment
self.nextAction = nextAction
self.deliveryType = deliveryType
self.deliveryTypeLabel = deliveryTypeLabel
self.subtotal = subtotal
self.deliveryFee = deliveryFee
self.discount = discount
self.total = total
self.storeName = storeName
self.storePhone = storePhone
self.storeLogoURL = storeLogoURL
self.createdAt = createdAt
self.updatedAt = updatedAt
self.otp = otp
self.customerOtp = customerOtp
self.confirmOtp = confirmOtp
self.cancellationReason = cancellationReason
self.fullAddress = fullAddress
self.deliveryAddress = deliveryAddress
self.review = review
self.items = items
self.timeline = timeline
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
id = ApiService.decodeFlexibleString(from: container, keys: [.id]) ?? UUID().uuidString
shortId = ApiService.decodeFlexibleString(from: container, keys: [.shortId])
realId = ApiService.decodeFlexibleString(from: container, keys: [.realId])
storeId = ApiService.decodeFlexibleString(from: container, keys: [.storeId, .store_id])
status = ApiService.decodeFlexibleString(from: container, keys: [.status])
paymentStatus = ApiService.decodeFlexibleString(from: container, keys: [.paymentStatus])
paymentConfirmed = try? container.decode(Bool.self, forKey: .paymentConfirmed)
paymentMethod = ApiService.decodeFlexibleString(from: container, keys: [.paymentMethod])
paymentMethodCode = ApiService.decodeFlexibleString(from: container, keys: [.paymentMethodCode])
nextAction = ApiService.decodeFlexibleString(from: container, keys: [.nextAction])
payment = try? container.decode(CreateOrderPaymentInfo.self, forKey: .payment)
if let objectPayload = try? container.decode(CreateOrderPaymentPayload.self, forKey: .paymentPayload) {
paymentPayload = objectPayload
} else if let stringPayload = try? container.decode(String.self, forKey: .paymentPayload) {
paymentPayload = CreateOrderPaymentPayload(
copyPaste: stringPayload,
qrCodeImage: nil,
expirationDate: nil
)
} else {
paymentPayload = nil
}
deliveryType = ApiService.decodeFlexibleString(from: container, keys: [.deliveryType])
deliveryTypeLabel = ApiService.decodeFlexibleString(from: container, keys: [.deliveryTypeLabel])
subtotal = ApiService.decodeFlexibleDouble(from: container, keys: [.subtotal, .subTotal, .itemsTotal])
deliveryFee = ApiService.decodeFlexibleDouble(from: container, keys: [.deliveryFee, .delivery_fee, .fee])
discount = ApiService.decodeFlexibleDouble(from: container, keys: [.discount, .desconto, .couponDiscount])
total = ApiService.decodeFlexibleDouble(from: container, keys: [.total])
storeName = ApiService.decodeFlexibleString(from: container, keys: [.storeName])
storePhone = ApiService.decodeFlexibleString(from: container, keys: [.storePhone])
storeLogoURL = ApiService.decodeFlexibleString(from: container, keys: [.storeLogo, .store_logo, .logo, .storeImage, .store_image, .storeImageUrl, .logoUrl])
createdAt = ApiService.decodeFlexibleString(from: container, keys: [.createdAt, .orderedAt])
updatedAt = ApiService.decodeFlexibleString(from: container, keys: [.updatedAt])
otp = ApiService.decodeFlexibleString(from: container, keys: [.otp])
customerOtp = ApiService.decodeFlexibleString(from: container, keys: [.customerOtp])
confirmOtp = ApiService.decodeFlexibleString(from: container, keys: [.confirmOtp])
cancellationReason = ApiService.decodeFlexibleString(from: container, keys: [.cancellationReason])
fullAddress = ApiService.decodeFlexibleString(from: container, keys: [.fullAddress])
deliveryAddress = (try? container.decode(PublicOrderDeliveryAddress.self, forKey: .address))
?? (try? container.decode(PublicOrderDeliveryAddress.self, forKey: .deliveryAddress))
?? (try? container.decode(PublicOrderDeliveryAddress.self, forKey: .delivery_address))
?? (try? container.decode(PublicOrderDeliveryAddress.self, forKey: .customerAddress))
?? (try? container.decode(PublicOrderDeliveryAddress.self, forKey: .customer_address))
review = (try? container.decode(PublicOrderReview.self, forKey: .review))
?? (try? container.decode(PublicOrderReview.self, forKey: .orderReview))
items = (try? container.decode([PublicOrderItem].self, forKey: .items)) ?? []
timeline = (try? container.decode([PublicOrderTimelineEvent].self, forKey: .timeline))
?? (try? container.decode([PublicOrderTimelineEvent].self, forKey: .history))
?? []
}
func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(id, forKey: .id)
try container.encodeIfPresent(shortId, forKey: .shortId)
try container.encodeIfPresent(realId, forKey: .realId)
try container.encodeIfPresent(storeId, forKey: .storeId)
try container.encodeIfPresent(status, forKey: .status)
try container.encodeIfPresent(paymentStatus, forKey: .paymentStatus)
try container.encodeIfPresent(paymentConfirmed, forKey: .paymentConfirmed)
try container.encodeIfPresent(paymentMethod, forKey: .paymentMethod)
try container.encodeIfPresent(paymentMethodCode, forKey: .paymentMethodCode)
try container.encodeIfPresent(paymentPayload, forKey: .paymentPayload)
try container.encodeIfPresent(payment, forKey: .payment)
try container.encodeIfPresent(nextAction, forKey: .nextAction)
try container.encodeIfPresent(deliveryType, forKey: .deliveryType)
try container.encodeIfPresent(deliveryTypeLabel, forKey: .deliveryTypeLabel)
try container.encodeIfPresent(subtotal, forKey: .subtotal)
try container.encodeIfPresent(deliveryFee, forKey: .deliveryFee)
try container.encodeIfPresent(discount, forKey: .discount)
try container.encodeIfPresent(total, forKey: .total)
try container.encodeIfPresent(storeName, forKey: .storeName)
try container.encodeIfPresent(storePhone, forKey: .storePhone)
try container.encodeIfPresent(createdAt, forKey: .createdAt)
try container.encodeIfPresent(updatedAt, forKey: .updatedAt)
try container.encodeIfPresent(otp, forKey: .otp)
try container.encodeIfPresent(customerOtp, forKey: .customerOtp)
try container.encodeIfPresent(confirmOtp, forKey: .confirmOtp)
try container.encodeIfPresent(cancellationReason, forKey: .cancellationReason)
try container.encodeIfPresent(fullAddress, forKey: .fullAddress)
try container.encodeIfPresent(deliveryAddress, forKey: .address)
try container.encodeIfPresent(review, forKey: .review)
try container.encode(items, forKey: .items)
try container.encode(timeline, forKey: .timeline)
}
var displayOtpCode: String? {
let values = [customerOtp, otp, confirmOtp]
for value in values {
let trimmed = (value ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed.isEmpty == false { return trimmed }
}
return nil
}
var isInDeliveryRoute: Bool {
let normalized = (status ?? "").uppercased()
if normalized.contains("OUT_FOR_DELIVERY") { return true }
if normalized.contains("EM_ROTA") { return true }
if normalized.contains("ON_ROUTE") { return true }
if normalized.contains("ROTA") { return true }
return false
}
var isFinalStatus: Bool {
let normalized = (status ?? "").uppercased()
return normalized == "COMPLETED" || normalized == "CANCELED" || normalized == "REFUNDED"
}
var isPaymentConfirmed: Bool {
if let paymentConfirmed {
return paymentConfirmed
}
let payment = (paymentStatus ?? "").uppercased()
let currentStatus = (status ?? "").uppercased()
if Self.looksConfirmed(payment) || Self.looksConfirmed(currentStatus) {
return true
}
// Fallback: alguns ambientes atualizam apenas a timeline primeiro.
if timeline.contains(where: { event in
let statusValue = (event.status ?? "").uppercased()
let messageValue = (event.message ?? "").uppercased()
return Self.looksConfirmed(statusValue) || Self.looksConfirmed(messageValue)
}) {
return true
}
return false
}
private static func looksConfirmed(_ value: String) -> Bool {
if value.isEmpty { return false }
if value.contains("PENDING") || value.contains("AWAIT") { return false }
if value.contains("FAILED") || value.contains("ERROR") { return false }
if value.contains("CANCEL") || value.contains("REFUND") { return false }
if value.contains("CONFIRM") { return true }
if value.contains("APPROV") { return true }
if value.contains("PAID") { return true }
if value.contains("RECEIV") { return true }
return value == "SUCCESS" || value == "DONE"
}
}
struct PublicOrderDeliveryAddress: Codable {
let label: String?
let street: String?
let number: String?
let neighborhood: String?
let city: String?
let state: String?
let zip: String?
let complement: String?
enum CodingKeys: String, CodingKey {
case label
case street
case address
case number
case neighborhood
case district
case city
case state
case zip
case zipCode
case zipcode
case complement
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
label = ApiService.decodeFlexibleString(from: container, keys: [.label])
street = ApiService.decodeFlexibleString(from: container, keys: [.street, .address])
number = ApiService.decodeFlexibleString(from: container, keys: [.number])
neighborhood = ApiService.decodeFlexibleString(from: container, keys: [.neighborhood, .district])
city = ApiService.decodeFlexibleString(from: container, keys: [.city])
state = ApiService.decodeFlexibleString(from: container, keys: [.state])
zip = ApiService.decodeFlexibleString(from: container, keys: [.zip, .zipCode, .zipcode])
complement = ApiService.decodeFlexibleString(from: container, keys: [.complement])
}
func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encodeIfPresent(label, forKey: .label)
try container.encodeIfPresent(street, forKey: .street)
try container.encodeIfPresent(number, forKey: .number)
try container.encodeIfPresent(neighborhood, forKey: .neighborhood)
try container.encodeIfPresent(city, forKey: .city)
try container.encodeIfPresent(state, forKey: .state)
try container.encodeIfPresent(zip, forKey: .zip)
try container.encodeIfPresent(complement, forKey: .complement)
}
}
struct PublicOrderReview: Codable {
let id: String?
let orderId: String?
let rate: Int?
let message: String?
let orderRate: Int?
let orderComment: String?
let orderPositiveTags: [String]?
let orderImprovementTags: [String]?
let deliverySentiment: String?
let deliveryPositiveTags: [String]?
let deliveryNegativeTags: [String]?
let appNps: Int?
let platform: String?
let date: String?
enum CodingKeys: String, CodingKey {
case id
case orderId
case rate
case message
case orderRate
case orderComment
case orderPositiveTags
case orderImprovementTags
case itemFeedback
case improvementFeedback
case deliverySentiment
case deliveryFeedback
case deliveryPositiveTags
case deliveryNegativeTags
case appNps
case app_nps
case platform
case date
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
id = ApiService.decodeFlexibleString(from: container, keys: [.id])
orderId = ApiService.decodeFlexibleString(from: container, keys: [.orderId])
rate = ApiService.decodeFlexibleInt(from: container, keys: [.rate])
message = ApiService.decodeFlexibleString(from: container, keys: [.message])
orderRate = ApiService.decodeFlexibleInt(from: container, keys: [.orderRate, .rate])
orderComment = ApiService.decodeFlexibleString(from: container, keys: [.orderComment, .message])
orderPositiveTags = Self.decodeStringList(
from: container,
keys: [.orderPositiveTags, .itemFeedback]
)
orderImprovementTags = Self.decodeStringList(
from: container,
keys: [.orderImprovementTags, .improvementFeedback]
)
deliverySentiment = ApiService.decodeFlexibleString(from: container, keys: [.deliverySentiment, .deliveryFeedback])
deliveryPositiveTags = Self.decodeStringList(from: container, keys: [.deliveryPositiveTags])
deliveryNegativeTags = Self.decodeStringList(from: container, keys: [.deliveryNegativeTags])
appNps = Self.decodeNps(from: container, keys: [.appNps, .app_nps])
platform = ApiService.decodeFlexibleString(from: container, keys: [.platform])
date = ApiService.decodeFlexibleString(from: container, keys: [.date])
}
private static func decodeStringList(
from container: KeyedDecodingContainer<CodingKeys>,
keys: [CodingKeys]
) -> [String]? {
for key in keys {
if let list = try? container.decode([String].self, forKey: key) {
return list
}
if let single = try? container.decode(String.self, forKey: key) {
let normalized = single.trimmingCharacters(in: .whitespacesAndNewlines)
if normalized.isEmpty == false {
return [normalized]
}
}
}
return nil
}
private static func decodeNps(
from container: KeyedDecodingContainer<CodingKeys>,
keys: [CodingKeys]
) -> Int? {
for key in keys {
if let value = try? container.decode(Int.self, forKey: key) {
return value
}
if let value = try? container.decode(Double.self, forKey: key) {
return Int(value.rounded())
}
if let raw = try? container.decode(String.self, forKey: key) {
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed.isEmpty { continue }
if let asInt = Int(trimmed) {
return asInt
}
let normalized = trimmed.replacingOccurrences(of: ",", with: ".")
if let asDouble = Double(normalized) {
return Int(asDouble.rounded())
}
}
}
return nil
}
func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encodeIfPresent(id, forKey: .id)
try container.encodeIfPresent(orderId, forKey: .orderId)
try container.encodeIfPresent(rate, forKey: .rate)
try container.encodeIfPresent(message, forKey: .message)
try container.encodeIfPresent(orderRate, forKey: .orderRate)
try container.encodeIfPresent(orderComment, forKey: .orderComment)
try container.encodeIfPresent(orderPositiveTags, forKey: .orderPositiveTags)
try container.encodeIfPresent(orderImprovementTags, forKey: .orderImprovementTags)
try container.encodeIfPresent(deliverySentiment, forKey: .deliverySentiment)
try container.encodeIfPresent(deliveryPositiveTags, forKey: .deliveryPositiveTags)
try container.encodeIfPresent(deliveryNegativeTags, forKey: .deliveryNegativeTags)
try container.encodeIfPresent(appNps, forKey: .appNps)
try container.encodeIfPresent(platform, forKey: .platform)
try container.encodeIfPresent(date, forKey: .date)
}
}
struct PublicOrderItem: Codable, Identifiable {
let id: String
let productId: String?
let name: String?
let qty: Int?
let price: Double?
enum CodingKeys: String, CodingKey {
case id
case productId
case name
case qty
case quantity
case price
}
init(id: String = UUID().uuidString, productId: String? = nil, name: String?, qty: Int?, price: Double?) {
self.id = id
self.productId = productId
self.name = name
self.qty = qty
self.price = price
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
id = (try? container.decode(String.self, forKey: .id)) ?? UUID().uuidString
productId = try? container.decode(String.self, forKey: .productId)
name = try? container.decode(String.self, forKey: .name)
qty = ApiService.decodeFlexibleInt(from: container, keys: [.qty, .quantity])
price = ApiService.decodeFlexibleDouble(from: container, keys: [.price])
}
func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(id, forKey: .id)
try container.encodeIfPresent(productId, forKey: .productId)
try container.encodeIfPresent(name, forKey: .name)
try container.encodeIfPresent(qty, forKey: .qty)
try container.encodeIfPresent(price, forKey: .price)
}
}
struct PublicOrderTimelineEvent: Codable, Identifiable {
let id: String
let status: String?
let label: String?
let active: Bool?
let completed: Bool?
let message: String?
let time: String?
let date: String?
enum CodingKeys: String, CodingKey {
case id
case status
case label
case active
case completed
case message
case event
case time
case date
case createdAt
case updatedAt
}
init(
id: String = UUID().uuidString,
status: String?,
label: String? = nil,
active: Bool? = nil,
completed: Bool? = nil,
message: String?,
time: String?,
date: String? = nil
) {
self.id = id
self.status = status
self.label = label
self.active = active
self.completed = completed
self.message = message
self.time = time
self.date = date
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
id = (try? container.decode(String.self, forKey: .id)) ?? UUID().uuidString
status = try? container.decode(String.self, forKey: .status)
label = try? container.decode(String.self, forKey: .label)
active = try? container.decode(Bool.self, forKey: .active)
completed = try? container.decode(Bool.self, forKey: .completed)
message = (try? container.decode(String.self, forKey: .message))
?? (try? container.decode(String.self, forKey: .event))
time = (try? container.decode(String.self, forKey: .time))
?? (try? container.decode(String.self, forKey: .createdAt))
?? (try? container.decode(String.self, forKey: .updatedAt))
date = try? container.decode(String.self, forKey: .date)
}
func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(id, forKey: .id)
try container.encodeIfPresent(status, forKey: .status)
try container.encodeIfPresent(label, forKey: .label)
try container.encodeIfPresent(active, forKey: .active)
try container.encodeIfPresent(completed, forKey: .completed)
try container.encodeIfPresent(message, forKey: .message)
try container.encodeIfPresent(time, forKey: .time)
try container.encodeIfPresent(date, forKey: .date)
}
}
struct OrderRealtimeUpdate: Decodable {
let id: String?
let shortId: String?
let storeId: String?
let userId: String?
let status: String?
let paymentStatus: String?
let updatedAt: String?
enum CodingKeys: String, CodingKey {
case id
case shortId
case storeId
case userId
case status
case paymentStatus
case updatedAt
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
id = ApiService.decodeFlexibleString(from: container, keys: [.id])
shortId = ApiService.decodeFlexibleString(from: container, keys: [.shortId])
storeId = ApiService.decodeFlexibleString(from: container, keys: [.storeId])
userId = ApiService.decodeFlexibleString(from: container, keys: [.userId])
status = ApiService.decodeFlexibleString(from: container, keys: [.status])
paymentStatus = ApiService.decodeFlexibleString(from: container, keys: [.paymentStatus])
updatedAt = ApiService.decodeFlexibleString(from: container, keys: [.updatedAt])
}
}
extension CreateOrderResult {
func asPublicOrderResult() -> PublicOrderResult {
PublicOrderResult(
id: id ?? UUID().uuidString,
shortId: shortId,
status: status,
paymentStatus: paymentStatus,
paymentConfirmed: paymentConfirmed,
paymentMethod: paymentMethod,
paymentPayload: paymentPayload,
payment: payment
)
}
}

View File

@@ -0,0 +1,80 @@
import Foundation
struct StorePizzaConfig: Decodable {
let sizes: [StorePizzaSize]
let doughs: [StorePizzaDough]
let crusts: [StorePizzaCrust]
enum CodingKeys: String, CodingKey {
case sizes
case doughs
case crusts
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
sizes = (try? container.decode([StorePizzaSize].self, forKey: .sizes)) ?? []
doughs = (try? container.decode([StorePizzaDough].self, forKey: .doughs)) ?? []
crusts = (try? container.decode([StorePizzaCrust].self, forKey: .crusts)) ?? []
}
}
struct StorePizzaSize: Decodable, Identifiable {
let id: String
let name: String?
let slices: Int?
let maxFlavors: Int?
enum CodingKeys: String, CodingKey {
case id
case name
case slices
case maxFlavors
}
}
struct StorePizzaDough: Decodable, Identifiable {
let id: String
let name: String?
let active: Bool?
enum CodingKeys: String, CodingKey {
case id
case name
case active
}
}
struct StorePizzaCrust: Decodable, Identifiable {
let id: String
let name: String?
let active: Bool?
let priceModifier: Double?
enum CodingKeys: String, CodingKey {
case id
case name
case active
case priceModifier
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
id = (try? container.decode(String.self, forKey: .id)) ?? UUID().uuidString
name = try? container.decode(String.self, forKey: .name)
active = try? container.decode(Bool.self, forKey: .active)
if let value = try? container.decode(Double.self, forKey: .priceModifier) {
priceModifier = value
} else if let value = try? container.decode(Int.self, forKey: .priceModifier) {
priceModifier = Double(value)
} else if let value = try? container.decode(String.self, forKey: .priceModifier) {
let normalized = value
.trimmingCharacters(in: .whitespacesAndNewlines)
.replacingOccurrences(of: ",", with: ".")
priceModifier = Double(normalized)
} else {
priceModifier = nil
}
}
}

View File

@@ -0,0 +1,268 @@
import Foundation
enum ReviewPlatform: String, Encodable {
case ios
case android
case web
static var current: ReviewPlatform {
#if os(iOS)
return .ios
#else
return .web
#endif
}
}
struct SubmitOrderReviewPayload: Encodable {
let rate: Int
let message: String
let orderRate: Int
let orderComment: String
let orderPositiveTags: [String]
let orderImprovementTags: [String]
let deliverySentiment: String
let deliveryPositiveTags: [String]
let deliveryNegativeTags: [String]
let appNps: Int
let platform: String
}
struct ReviewTagItem: Decodable, Hashable, Identifiable {
let id: String
let label: String
}
struct ReviewOrderTagRules: Decodable {
let positiveAllowedWhenRateGte: Int?
let improvementAllowedWhenRateLte: Int?
}
struct ReviewOrderTagsCatalog: Decodable {
let positive: [ReviewTagItem]
let improvement: [ReviewTagItem]
let rules: ReviewOrderTagRules?
}
struct ReviewDeliverySentimentRule: Decodable {
let id: String
let allowedTags: [String]
}
struct ReviewDeliveryTagsCatalog: Decodable {
let sentiments: [ReviewDeliverySentimentRule]
let positive: [ReviewTagItem]
let negative: [ReviewTagItem]
}
struct ReviewNpsCatalog: Decodable {
let min: Int?
let max: Int?
}
struct ReviewAppTagsCatalog: Decodable {
let nps: ReviewNpsCatalog?
let platforms: [String]?
}
struct ReviewTagsCatalog: Decodable {
let version: String?
let order: ReviewOrderTagsCatalog?
let delivery: ReviewDeliveryTagsCatalog?
let app: ReviewAppTagsCatalog?
}
struct SubmitOrderReviewResult: Decodable {
let id: String?
let storeId: String?
let userId: String?
let clientName: String?
let rate: Int?
let message: String?
let orderRate: Int?
let orderComment: String?
let deliverySentiment: String?
let orderPositiveTags: [String]?
let orderImprovementTags: [String]?
let deliveryPositiveTags: [String]?
let deliveryNegativeTags: [String]?
let appNps: Int?
let platform: String?
let orderId: String?
let date: String?
let editableUntil: String?
let storeReplyUntil: String?
let reviewWindowExpiresAt: String?
let storeReplyMessage: String?
let storeReplyAt: String?
enum CodingKeys: String, CodingKey {
case id
case storeId
case userId
case clientName
case rate
case message
case orderRate
case orderComment
case deliverySentiment
case deliveryFeedback
case itemFeedback
case improvementFeedback
case orderPositiveTags
case orderImprovementTags
case deliveryPositiveTags
case deliveryNegativeTags
case appNps
case app_nps
case platform
case orderId
case date
case editableUntil
case storeReplyUntil
case reviewWindowExpiresAt
case storeReply
case store_response
case storeResponse
case reply
case storeReplyMessage
case storeReplyAt
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
id = ApiService.decodeFlexibleString(from: container, keys: [.id])
storeId = ApiService.decodeFlexibleString(from: container, keys: [.storeId])
userId = ApiService.decodeFlexibleString(from: container, keys: [.userId])
clientName = ApiService.decodeFlexibleString(from: container, keys: [.clientName])
rate = ApiService.decodeFlexibleInt(from: container, keys: [.rate])
message = ApiService.decodeFlexibleString(from: container, keys: [.message])
orderRate = ApiService.decodeFlexibleInt(from: container, keys: [.orderRate, .rate])
orderComment = ApiService.decodeFlexibleString(from: container, keys: [.orderComment, .message])
deliverySentiment = ApiService.decodeFlexibleString(from: container, keys: [.deliverySentiment, .deliveryFeedback])
orderPositiveTags = Self.decodeStringList(from: container, keys: [.orderPositiveTags, .itemFeedback])
orderImprovementTags = Self.decodeStringList(from: container, keys: [.orderImprovementTags, .improvementFeedback])
deliveryPositiveTags = (try? container.decode([String].self, forKey: .deliveryPositiveTags)) ?? nil
deliveryNegativeTags = (try? container.decode([String].self, forKey: .deliveryNegativeTags)) ?? nil
appNps = Self.decodeNps(from: container, keys: [.appNps, .app_nps])
platform = ApiService.decodeFlexibleString(from: container, keys: [.platform])
orderId = ApiService.decodeFlexibleString(from: container, keys: [.orderId])
date = ApiService.decodeFlexibleString(from: container, keys: [.date])
editableUntil = ApiService.decodeFlexibleString(from: container, keys: [.editableUntil])
storeReplyUntil = ApiService.decodeFlexibleString(from: container, keys: [.storeReplyUntil])
reviewWindowExpiresAt = ApiService.decodeFlexibleString(from: container, keys: [.reviewWindowExpiresAt])
storeReplyMessage = Self.decodeReplyMessage(from: container)
storeReplyAt = Self.decodeReplyDate(from: container)
}
private static func decodeStringList(
from container: KeyedDecodingContainer<CodingKeys>,
keys: [CodingKeys]
) -> [String]? {
for key in keys {
if let list = try? container.decode([String].self, forKey: key) {
return list
}
if let single = try? container.decode(String.self, forKey: key) {
let normalized = single.trimmingCharacters(in: .whitespacesAndNewlines)
if normalized.isEmpty == false {
return [normalized]
}
}
}
return nil
}
private static func decodeNps(
from container: KeyedDecodingContainer<CodingKeys>,
keys: [CodingKeys]
) -> Int? {
for key in keys {
if let value = try? container.decode(Int.self, forKey: key) {
return value
}
if let value = try? container.decode(Double.self, forKey: key) {
return Int(value.rounded())
}
if let raw = try? container.decode(String.self, forKey: key) {
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed.isEmpty { continue }
if let asInt = Int(trimmed) {
return asInt
}
let normalized = trimmed.replacingOccurrences(of: ",", with: ".")
if let asDouble = Double(normalized) {
return Int(asDouble.rounded())
}
}
}
return nil
}
private static func decodeReplyMessage(from container: KeyedDecodingContainer<CodingKeys>) -> String? {
if let value = ApiService.decodeFlexibleString(from: container, keys: [.storeReplyMessage]) {
return value
}
if let value = ApiService.decodeFlexibleString(from: container, keys: [.storeReply, .store_response, .storeResponse, .reply]) {
return value
}
for key in [CodingKeys.storeReply, .store_response, .storeResponse, .reply] {
if let object = try? container.decode([String: String].self, forKey: key) {
let candidates = ["message", "text", "reply", "content", "body"]
for candidate in candidates {
let value = (object[candidate] ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
if value.isEmpty == false { return value }
}
}
}
return nil
}
private static func decodeReplyDate(from container: KeyedDecodingContainer<CodingKeys>) -> String? {
if let value = ApiService.decodeFlexibleString(from: container, keys: [.storeReplyAt]) {
return value
}
for key in [CodingKeys.storeReply, .store_response, .storeResponse, .reply] {
if let object = try? container.decode([String: String].self, forKey: key) {
let candidates = ["date", "createdAt", "updatedAt", "repliedAt", "replyAt"]
for candidate in candidates {
let value = (object[candidate] ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
if value.isEmpty == false { return value }
}
}
}
return nil
}
}
struct PublicStoreReviewsResult: Decodable {
let reviews: [SubmitOrderReviewResult]
enum CodingKeys: String, CodingKey {
case reviews
case data
case items
}
init(from decoder: Decoder) throws {
if let list = try? [SubmitOrderReviewResult](from: decoder) {
reviews = list
return
}
let container = try decoder.container(keyedBy: CodingKeys.self)
if let list = try? container.decode([SubmitOrderReviewResult].self, forKey: .reviews) {
reviews = list
return
}
if let list = try? container.decode([SubmitOrderReviewResult].self, forKey: .data) {
reviews = list
return
}
if let list = try? container.decode([SubmitOrderReviewResult].self, forKey: .items) {
reviews = list
return
}
reviews = []
}
}

View File

@@ -0,0 +1,650 @@
import Foundation
enum ApiServiceError: Error, LocalizedError {
case sessionExpired(String?)
var errorDescription: String? {
switch self {
case .sessionExpired(let message):
return message ?? "Sessao expirada. Faca login novamente."
}
}
}
struct ApiEnvelope<T: Decodable & Sendable>: Decodable, Sendable {
let error: Bool
let code: String?
let message: String?
let result: T?
}
final class ApiService {
private let client: ApiClient
private var tokenStore: TokenStore
private let profileCachePrefix = "api:profile:"
private let ordersCachePrefix = "api:orders:"
private let favoritesCachePrefix = "api:favorites:"
private let publicCategoriesCacheKey = "api:public-categories"
init(client: ApiClient = ApiClient(), tokenStore: TokenStore = DefaultTokenStore()) {
self.client = client
self.tokenStore = tokenStore
}
private func send<T: Decodable & Sendable>(_ req: ApiRequest) async throws -> T {
do {
return try await client.send(req)
} catch let error as NetworkError {
if canTriggerSessionExpiry(for: req), case .unauthorized(let message) = error {
expireSession(message)
throw ApiServiceError.sessionExpired(message)
}
throw error
}
}
private func sendEnvelope<T: Decodable>(_ req: ApiRequest) async throws -> ApiEnvelope<T> {
let envelope: ApiEnvelope<T> = try await send(req)
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()
let message = (envelope.message ?? "").lowercased()
if code.contains("auth") || code.contains("token") || code.contains("unauthorized") {
return true
}
if message.contains("token") && (message.contains("expir") || message.contains("invalid") || message.contains("sess")) {
return true
}
return false
}
private func expireSession(_ message: String?) {
tokenStore.clear()
AppContentCache.shared.invalidate(prefix: profileCachePrefix)
AppContentCache.shared.invalidate(prefix: ordersCachePrefix)
AppContentCache.shared.invalidate(prefix: favoritesCachePrefix)
AppContentCache.shared.invalidate(prefix: publicCategoriesCacheKey)
NotificationCenter.default.post(name: .sessionExpired, object: message)
}
private func invalidateFavoritesCache() {
AppContentCache.shared.invalidate(prefix: profileCachePrefix)
AppContentCache.shared.invalidate(prefix: favoritesCachePrefix)
}
/// Every successful profile mutation must leave `profile()`'s cache
/// holding the server's authoritative post-mutation state never
/// patched locally from a write-response of possibly different shape,
/// and never left merely invalidated for some future caller to lazily
/// refetch (which may never happen, leaving stale data visible
/// indefinitely within the TTL). Always does a real GET.
@discardableResult
private func refreshProfileCache() async -> ApiEnvelope<CustomerProfile>? {
try? await profile(forceRefresh: true)
}
private func scopedCacheSuffix() -> String {
let jwt = tokenStore.jwt ?? "anonymous"
if jwt.count <= 16 { return jwt }
return String(jwt.prefix(16))
}
// MARK: - Auth
func registerCustomer(name: String, email: String, phoneNumber: String, birthDate: String? = nil) async throws -> ApiEnvelope<RegistrationResult> {
var payload: [String: String] = [
"name": name,
"email": email,
"phoneNumber": phoneNumber
]
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)
}
func requestOtp(email: String, phoneNumber: String) async throws -> ApiEnvelope<EmptyResult> {
let body = try makeLoginBody(email: email, phoneNumber: phoneNumber, otp: nil)
let req = ApiRequest(path: "/api/customer/login", method: "POST", module: .customer, requiresAuth: false, body: body)
return try await sendEnvelope(req)
}
func validateOtp(email: String, phoneNumber: String, otp: String) async throws -> ApiEnvelope<LoginResult> {
let body = try makeLoginBody(email: email, phoneNumber: phoneNumber, otp: otp)
let req = ApiRequest(path: "/api/customer/login", method: "POST", module: .customer, requiresAuth: false, body: body)
let response: ApiEnvelope<LoginResult> = try await sendEnvelope(req)
if let token = response.result?.token {
tokenStore.jwt = token
}
return response
}
private func makeLoginBody(email: String, phoneNumber: String, otp: String?) throws -> Data {
let sanitizedEmail = email.trimmingCharacters(in: .whitespacesAndNewlines)
let sanitizedPhone = phoneNumber.trimmingCharacters(in: .whitespacesAndNewlines)
guard sanitizedEmail.isEmpty == false, sanitizedPhone.isEmpty == false else {
throw NetworkError.httpError(400, "Email e telefone são obrigatórios.")
}
var payload: [String: String] = [
"email": sanitizedEmail,
"phoneNumber": sanitizedPhone,
"phone": sanitizedPhone
]
if let otp, otp.isEmpty == false {
payload["otp"] = otp
}
guard JSONSerialization.isValidJSONObject(payload) else {
throw NetworkError.invalidResponse
}
return try JSONSerialization.data(withJSONObject: payload, options: [])
}
func profile(forceRefresh: Bool = false) async throws -> ApiEnvelope<CustomerProfile> {
let cacheKey = "\(profileCachePrefix)\(scopedCacheSuffix())"
if forceRefresh == false,
let cached: ApiEnvelope<CustomerProfile> = AppContentCache.shared.value(for: cacheKey, as: ApiEnvelope<CustomerProfile>.self) {
return cached
}
let req = ApiRequest(path: "/api/customer/profile", method: "GET", module: .customer, requiresAuth: true)
let envelope: ApiEnvelope<CustomerProfile> = try await sendEnvelope(req)
if envelope.error == false, envelope.result != nil {
AppContentCache.shared.set(envelope, for: cacheKey, ttl: AppCacheTTL.twoHours)
}
return envelope
}
func updateCustomerProfile(name: String, email: String, phoneNumber: String, profilePicture: String?) async throws -> ProfilePatchEnvelope {
let payload = CustomerIdentityUpdatePayload(
name: name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? nil : name,
email: email.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? nil : email,
phoneNumber: phoneNumber.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? nil : phoneNumber,
profilePicture: profilePicture
)
let body = try JSONEncoder().encode(payload)
let req = ApiRequest(path: "/api/customer/profile", method: "PATCH", module: .customer, requiresAuth: true, body: body)
let envelope: ProfilePatchEnvelope = try await send(req)
if envelope.error == false {
await refreshProfileCache()
}
return envelope
}
/// See docs/api/push-notifications-integration-guide.md §2b only reachable
/// via `POST /api/customer/:id` today, not `PATCH /profile`.
func updateNotificationsEnabled(_ enabled: Bool) async throws -> ApiEnvelope<CustomerProfile> {
let currentProfile = try await profile(forceRefresh: true)
guard currentProfile.error == false, let customer = currentProfile.result else {
throw NetworkError.invalidResponse
}
let payload = CustomerNotificationsUpdatePayload(notificationsEnabled: enabled)
let body = try JSONEncoder().encode(payload)
let req = ApiRequest(path: "/api/customer/\(customer.id)", method: "POST", module: .customer, requiresAuth: true, body: body)
let envelope: ApiEnvelope<CustomerProfile> = try await sendEnvelope(req)
if envelope.error == false {
await refreshProfileCache()
}
return envelope
}
/// Persists the biometric-login preference only no LocalAuthentication
/// wiring yet, that's a separate later plan.
func updateFaceIdEnabled(_ enabled: Bool) async throws -> ApiEnvelope<CustomerProfile> {
let currentProfile = try await profile(forceRefresh: true)
guard currentProfile.error == false, let customer = currentProfile.result else {
throw NetworkError.invalidResponse
}
let payload = CustomerFaceIdUpdatePayload(faceIdEnabled: enabled)
let body = try JSONEncoder().encode(payload)
let req = ApiRequest(path: "/api/customer/\(customer.id)", method: "POST", module: .customer, requiresAuth: true, body: body)
let envelope: ApiEnvelope<CustomerProfile> = try await sendEnvelope(req)
if envelope.error == false {
await refreshProfileCache()
}
return envelope
}
/// See docs/api/push-notifications-integration-guide.md §2.
func registerPushToken(_ token: String, deviceId: String, deviceOS: String) async throws -> ApiEnvelope<EmptyResult> {
let currentProfile = try await profile(forceRefresh: true)
guard currentProfile.error == false, let customer = currentProfile.result else {
throw NetworkError.invalidResponse
}
let payload = CustomerPushTokenPayload(pushToken: token, deviceId: deviceId, deviceOS: deviceOS)
let body = try JSONEncoder().encode(payload)
let req = ApiRequest(path: "/api/customer/\(customer.id)/push-token", method: "PUT", module: .customer, requiresAuth: true, body: body)
return try await sendEnvelope(req)
}
/// See docs/api/push-notifications-integration-guide.md §2a. Wholesale
/// replace, not a merge callers must pass every `attributes` key they
/// still want kept, not just the changed ones.
func updateCustomerAttributes(appVersion: String?, attributes: [String: String]?) async throws -> ApiEnvelope<EmptyResult> {
let currentProfile = try await profile(forceRefresh: true)
guard currentProfile.error == false, let customer = currentProfile.result else {
throw NetworkError.invalidResponse
}
let payload = CustomerAttributesUpdatePayload(appVersion: appVersion, attributes: attributes)
let body = try JSONEncoder().encode(payload)
let req = ApiRequest(path: "/api/customer/\(customer.id)/attributes", method: "PUT", module: .customer, requiresAuth: true, body: body)
return try await sendEnvelope(req)
}
/// See docs/api/push-notifications-integration-guide.md §6a. Fire on tap
/// only, for `type: "campaign"` pushes idempotent server-side.
func reportPushCampaignOpened(campaignId: String) async throws -> ApiEnvelope<PushCampaignOpenedResult> {
let currentProfile = try await profile(forceRefresh: true)
guard currentProfile.error == false, let customer = currentProfile.result else {
throw NetworkError.invalidResponse
}
let payload = PushCampaignOpenedPayload(campaignId: campaignId)
let body = try JSONEncoder().encode(payload)
let req = ApiRequest(path: "/api/customer/\(customer.id)/push-campaigns/opened", method: "POST", module: .customer, requiresAuth: true, body: body)
return try await sendEnvelope(req)
}
func addCustomerAddress(_ address: CustomerAddress) async throws -> ApiEnvelope<CustomerProfile> {
return try await saveCustomerAddress(address, replacingAddressId: nil)
}
func saveCustomerAddress(_ address: CustomerAddress, replacingAddressId: String?) async throws -> ApiEnvelope<CustomerProfile> {
let currentProfile = try await profile(forceRefresh: true)
guard currentProfile.error == false, let customer = currentProfile.result else {
throw NetworkError.invalidResponse
}
let currentAddressBook = customer.addressBook ?? []
var addressBook = currentAddressBook.map(CustomerAddressPayload.init(from:))
let addressPayload = CustomerAddressPayload(from: address)
if let replacingAddressId,
let replaceIndex = currentAddressBook.firstIndex(where: { $0.id == replacingAddressId }) {
addressBook[replaceIndex] = addressPayload
} else {
addressBook.insert(addressPayload, at: 0)
}
return try await updateCustomerAddressBook(customerId: customer.id, addressBook: addressBook)
}
func deleteCustomerAddress(_ address: CustomerAddress) async throws -> ApiEnvelope<CustomerProfile> {
let currentProfile = try await profile(forceRefresh: true)
guard currentProfile.error == false, let customer = currentProfile.result else {
throw NetworkError.invalidResponse
}
var currentAddressBook = customer.addressBook ?? []
if let targetId = address.id, targetId.isEmpty == false {
if let index = currentAddressBook.firstIndex(where: { $0.id == targetId }) {
currentAddressBook.remove(at: index)
}
} else if let index = currentAddressBook.firstIndex(where: { matchesAddress($0, address) }) {
currentAddressBook.remove(at: index)
}
let updatedAddressBook = currentAddressBook.map(CustomerAddressPayload.init(from:))
return try await updateCustomerAddressBook(customerId: customer.id, addressBook: updatedAddressBook)
}
func setDefaultAddress(addressId: String) async throws -> ApiEnvelope<EmptyResult> {
let req = ApiRequest(path: "/api/customer/addresses/\(addressId)/default", method: "PATCH", module: .customer, requiresAuth: true)
let envelope: ApiEnvelope<EmptyResult> = try await sendEnvelope(req)
if envelope.error == false {
await refreshProfileCache()
}
return envelope
}
func lookupZipCode(_ zipCode: String) async throws -> ApiEnvelope<CepLookupResult> {
let digits = zipCode.filter(\.isNumber)
let normalized = String(digits.prefix(8))
let formatted: String
if normalized.count == 8 {
let prefix = String(normalized.prefix(5))
let suffix = String(normalized.dropFirst(5))
formatted = "\(prefix)-\(suffix)"
} else {
formatted = normalized
}
let req = ApiRequest(path: "/api/public/cep/\(formatted)", method: "GET", module: .none, requiresAuth: true)
return try await sendEnvelope(req)
}
private func updateCustomerAddressBook(customerId: String, addressBook: [CustomerAddressPayload]) async throws -> ApiEnvelope<CustomerProfile> {
let payload = CustomerProfileUpdatePayload(addressBook: addressBook)
let body = try JSONEncoder().encode(payload)
let req = ApiRequest(path: "/api/customer/\(customerId)", method: "POST", module: .customer, requiresAuth: true, body: body)
let envelope: ApiEnvelope<CustomerProfile> = try await sendEnvelope(req)
if envelope.error == false {
await refreshProfileCache()
} else {
invalidateFavoritesCache()
}
return envelope
}
func listFavoriteStores(forceRefresh: Bool = false) async throws -> ApiEnvelope<[StoreSummary]> {
let cacheKey = "\(favoritesCachePrefix)\(scopedCacheSuffix())"
if forceRefresh == false,
let cached: ApiEnvelope<[StoreSummary]> = AppContentCache.shared.value(for: cacheKey, as: ApiEnvelope<[StoreSummary]>.self) {
return cached
}
let req = ApiRequest(path: "/api/customer/favorites", method: "GET", module: .customer, requiresAuth: true)
let envelope: ApiEnvelope<[StoreSummary]> = try await sendEnvelope(req)
if envelope.error == false, envelope.result != nil {
AppContentCache.shared.set(envelope, for: cacheKey, ttl: AppCacheTTL.twoHours)
}
return envelope
}
func addStoreToFavorites(storeId: String) async throws -> ApiEnvelope<CustomerFavoritesMutationResult> {
let req = ApiRequest(path: "/api/customer/favorites/\(storeId)", method: "POST", module: .customer, requiresAuth: true)
let envelope: ApiEnvelope<CustomerFavoritesMutationResult> = try await sendEnvelope(req)
if envelope.error == false {
invalidateFavoritesCache()
}
return envelope
}
func removeStoreFromFavorites(storeId: String) async throws -> ApiEnvelope<CustomerFavoritesMutationResult> {
let req = ApiRequest(path: "/api/customer/favorites/\(storeId)", method: "DELETE", module: .customer, requiresAuth: true)
let envelope: ApiEnvelope<CustomerFavoritesMutationResult> = try await sendEnvelope(req)
if envelope.error == false {
invalidateFavoritesCache()
}
return envelope
}
func setStoreFavorite(storeId: String, isFavorite: Bool) async throws -> ApiEnvelope<CustomerFavoritesMutationResult> {
if isFavorite {
return try await addStoreToFavorites(storeId: storeId)
}
return try await removeStoreFromFavorites(storeId: storeId)
}
private func matchesAddress(_ lhs: CustomerAddress, _ rhs: CustomerAddress) -> Bool {
lhs.label == rhs.label &&
lhs.address == rhs.address &&
lhs.number == rhs.number &&
lhs.complement == rhs.complement &&
lhs.neighborhood == rhs.neighborhood &&
lhs.city == rhs.city &&
lhs.state == rhs.state &&
lhs.zipCode == rhs.zipCode
}
// MARK: - Stores
func listPublicCategories(forceRefresh: Bool = false) async throws -> ApiEnvelope<[PublicCategory]> {
if forceRefresh == false,
let cached: ApiEnvelope<[PublicCategory]> = AppContentCache.shared.value(for: publicCategoriesCacheKey, as: ApiEnvelope<[PublicCategory]>.self) {
return cached
}
// 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)
}
return envelope
}
func listStores(lat: Double? = nil, lng: Double? = nil, category: String? = nil, search: String? = nil) async throws -> ApiEnvelope<[StoreSummary]> {
var items: [URLQueryItem] = []
if let lat, let lng {
items.append(URLQueryItem(name: "lat", value: String(lat)))
items.append(URLQueryItem(name: "lng", value: String(lng)))
}
if let category {
items.append(URLQueryItem(name: "category", value: category))
}
if let search {
items.append(URLQueryItem(name: "search", value: search))
}
let req = ApiRequest(path: "/api/app/stores", method: "GET", module: .app, requiresAuth: true, queryItems: items)
return try await sendEnvelope(req)
}
func storeInfo(storeId: String) async throws -> ApiEnvelope<StoreInfoResult> {
let req = ApiRequest(path: "/api/store/\(storeId)/info", method: "GET", module: .store, requiresAuth: true)
return try await sendEnvelope(req)
}
func storeCatalog(storeId: String) async throws -> ApiEnvelope<[StoreCatalogCategory]> {
let req = ApiRequest(path: "/api/store/\(storeId)/catalog", method: "GET", module: .store, requiresAuth: true)
return try await sendEnvelope(req)
}
func createOrder(storeId: String, payload: CreateOrderPayload) async throws -> ApiEnvelope<CreateOrderResult> {
let body = try JSONEncoder().encode(payload)
let req = ApiRequest(path: "/api/store/\(storeId)/orders", method: "POST", module: .store, requiresAuth: true, body: body)
let envelope: ApiEnvelope<CreateOrderResult> = try await sendEnvelope(req)
if envelope.error == false {
AppContentCache.shared.invalidate(prefix: ordersCachePrefix)
}
return envelope
}
func validateDeliveryAddress(storeId: String, payload: ValidateDeliveryAddressPayload) async throws -> ApiEnvelope<ValidateDeliveryAddressResult> {
let body = try JSONEncoder().encode(payload)
let req = ApiRequest(path: "/api/store/\(storeId)/delivery/validate-address", method: "POST", module: .store, requiresAuth: true, body: body)
return try await sendEnvelope(req)
}
func listOrders(forceRefresh: Bool = false) async throws -> ApiEnvelope<[AppOrderSummary]> {
let cacheKey = "\(ordersCachePrefix)\(scopedCacheSuffix())"
if forceRefresh == false,
let cached: ApiEnvelope<[AppOrderSummary]> = AppContentCache.shared.value(for: cacheKey, as: ApiEnvelope<[AppOrderSummary]>.self) {
return cached
}
let req = ApiRequest(
path: "/api/app/orders",
method: "GET",
module: .app,
requiresAuth: true,
queryItems: [URLQueryItem(name: "_ts", value: String(Int(Date().timeIntervalSince1970)))]
)
let envelope: ApiEnvelope<[AppOrderSummary]> = try await sendEnvelope(req)
if envelope.error == false {
AppContentCache.shared.set(envelope, for: cacheKey, ttl: AppCacheTTL.twoHours)
}
return envelope
}
func publicOrder(orderId: String) async throws -> ApiEnvelope<PublicOrderResult> {
let req = ApiRequest(
path: "/api/public/orders/\(orderId)",
method: "GET",
module: .none,
requiresAuth: true,
queryItems: [URLQueryItem(name: "_ts", value: String(Int(Date().timeIntervalSince1970)))]
)
return try await sendEnvelope(req)
}
func submitOrderReview(orderId: String, payload: SubmitOrderReviewPayload) async throws -> ApiEnvelope<SubmitOrderReviewResult> {
let body = try JSONEncoder().encode(payload)
let req = ApiRequest(
path: "/api/public/orders/\(orderId)/review",
method: "POST",
module: .none,
requiresAuth: true,
body: body
)
return try await sendEnvelope(req)
}
func reviewTagsCatalog() async throws -> ApiEnvelope<ReviewTagsCatalog> {
let req = ApiRequest(
path: "/api/public/reviews/tags",
method: "GET",
module: .none,
requiresAuth: false
)
return try await sendEnvelope(req)
}
func publicStoreReviews(storeId: String) async throws -> ApiEnvelope<PublicStoreReviewsResult> {
let req = ApiRequest(
path: "/api/public/store/\(storeId)/reviews",
method: "GET",
module: .none,
requiresAuth: false,
queryItems: [URLQueryItem(name: "_ts", value: String(Int(Date().timeIntervalSince1970)))]
)
return try await sendEnvelope(req)
}
// MARK: - Cards
func listCards() async throws -> ApiEnvelope<[SavedCard]> {
let req = ApiRequest(path: "/api/customer/cards", method: "GET", module: .customer, requiresAuth: true)
return try await sendEnvelope(req)
}
func saveCard(payload: SaveCardPayload) async throws -> ApiEnvelope<SavedCardResult> {
let body = try JSONEncoder().encode(payload)
let req = ApiRequest(path: "/api/customer/cards", method: "POST", module: .customer, requiresAuth: true, body: body)
return try await sendEnvelope(req)
}
func updateCard(cardId: String, payload: UpdateCardPayload) async throws -> ApiEnvelope<SavedCardResult> {
let body = try JSONEncoder().encode(payload)
let req = ApiRequest(path: "/api/customer/cards/\(cardId)", method: "PATCH", module: .customer, requiresAuth: true, body: body)
return try await sendEnvelope(req)
}
func deleteCard(cardId: String) async throws -> ApiEnvelope<EmptyResult> {
let req = ApiRequest(path: "/api/customer/cards/\(cardId)", method: "DELETE", module: .customer, requiresAuth: true)
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)
return try await sendEnvelope(req)
}
// MARK: - Profile CPF
func updateProfileCpf(cpf: String) async throws -> ApiEnvelope<EmptyResult> {
let payload = ["cpf": cpf]
let body = try JSONEncoder().encode(payload)
let req = ApiRequest(path: "/api/customer/profile", method: "PATCH", module: .customer, requiresAuth: true, body: body)
let result: ApiEnvelope<EmptyResult> = try await sendEnvelope(req)
if result.error == false {
await refreshProfileCache()
}
return result
}
}
extension ApiService {
static func decodeFlexibleString<K: CodingKey>(from container: KeyedDecodingContainer<K>, keys: [K]) -> String? {
for key in keys {
if let value = try? container.decode(String.self, forKey: key) {
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed.isEmpty == false {
return trimmed
}
}
if let asInt = try? container.decode(Int.self, forKey: key) {
return String(asInt)
}
if let asDouble = try? container.decode(Double.self, forKey: key) {
if asDouble.rounded() == asDouble {
return String(Int(asDouble))
}
return String(asDouble)
}
}
return nil
}
static func decodeFlexibleDouble<K: CodingKey>(from container: KeyedDecodingContainer<K>, keys: [K]) -> Double? {
for key in keys {
if let value = try? container.decode(Double.self, forKey: key) {
return value
}
if let asInt = try? container.decode(Int.self, forKey: key) {
return Double(asInt)
}
if let asString = try? container.decode(String.self, forKey: key) {
let normalized = asString
.trimmingCharacters(in: .whitespacesAndNewlines)
.replacingOccurrences(of: ",", with: ".")
if let parsed = Double(normalized) {
return parsed
}
}
}
return nil
}
static func decodeFlexibleInt<K: CodingKey>(from container: KeyedDecodingContainer<K>, keys: [K]) -> Int? {
for key in keys {
if let value = try? container.decode(Int.self, forKey: key) {
return value
}
if let asDouble = try? container.decode(Double.self, forKey: key) {
return Int(asDouble)
}
if let asString = try? container.decode(String.self, forKey: key) {
let normalized = asString
.trimmingCharacters(in: .whitespacesAndNewlines)
.replacingOccurrences(of: ".", with: "")
.replacingOccurrences(of: ",", with: "")
if let parsed = Int(normalized) {
return parsed
}
}
}
return nil
}
}

View File

@@ -0,0 +1,131 @@
import Foundation
import UIKit
typealias PlatformImage = UIImage
enum AppCacheTTL {
static let twoHours: TimeInterval = 2 * 60 * 60
static let homeStores: TimeInterval = 5 * 60
}
enum AppCacheKey {
static let homeStoresLatestSnapshot = "home-stores.latest.snapshot"
}
final class AppContentCache: @unchecked Sendable {
static let shared = AppContentCache()
private struct Entry {
let value: Any
let expiry: Date
}
private var entries: [String: Entry] = [:]
private let queue = DispatchQueue(label: "com.pedifoods.content-cache", qos: .userInitiated)
private init() {}
func value<T>(for key: String, as type: T.Type = T.self) -> T? {
queue.sync {
guard let entry = entries[key] else { return nil }
if entry.expiry <= Date() {
entries.removeValue(forKey: key)
return nil
}
return entry.value as? T
}
}
func set<T>(_ value: T, for key: String, ttl: TimeInterval) {
queue.sync {
entries[key] = Entry(value: value, expiry: Date().addingTimeInterval(ttl))
}
}
func invalidate(prefix: String? = nil) {
queue.sync {
guard let prefix, prefix.isEmpty == false else {
entries.removeAll()
return
}
let keys = entries.keys.filter { $0.hasPrefix(prefix) }
for key in keys {
entries.removeValue(forKey: key)
}
}
}
}
final class AppImageCache: @unchecked Sendable {
static let shared = AppImageCache()
private struct Entry {
let image: PlatformImage
let expiry: Date
}
private var entries: [String: Entry] = [:]
private let queue = DispatchQueue(label: "com.pedifoods.image-cache", qos: .userInitiated)
private init() {
configureURLCacheIfNeeded()
}
func image(for url: URL, ttl: TimeInterval, forceRefresh: Bool = false) async -> PlatformImage? {
let key = url.absoluteString
let now = Date()
if forceRefresh == false {
let cached = queue.sync { entries[key] }
if let cached, cached.expiry > now {
return cached.image
}
}
var request = URLRequest(url: url)
request.timeoutInterval = 20
request.cachePolicy = forceRefresh ? .reloadIgnoringLocalCacheData : .returnCacheDataElseLoad
if forceRefresh == false,
let diskCached = URLCache.shared.cachedResponse(for: request),
let image = platformImage(from: diskCached.data) {
queue.sync {
entries[key] = Entry(image: image, expiry: now.addingTimeInterval(ttl))
}
return image
}
do {
let (data, response) = try await URLSession.shared.data(for: request)
guard let image = platformImage(from: data) else { return nil }
URLCache.shared.storeCachedResponse(CachedURLResponse(response: response, data: data), for: request)
queue.sync {
entries[key] = Entry(image: image, expiry: now.addingTimeInterval(ttl))
}
return image
} catch {
return nil
}
}
func invalidateAll() {
queue.sync {
entries.removeAll()
}
URLCache.shared.removeAllCachedResponses()
}
private func configureURLCacheIfNeeded() {
let current = URLCache.shared
let minMemoryCapacity = 64 * 1024 * 1024
let minDiskCapacity = 256 * 1024 * 1024
if current.memoryCapacity < minMemoryCapacity || current.diskCapacity < minDiskCapacity {
URLCache.shared = URLCache(memoryCapacity: minMemoryCapacity, diskCapacity: minDiskCapacity)
}
}
private func platformImage(from data: Data) -> PlatformImage? {
UIImage(data: data)
}
}

View File

@@ -0,0 +1,278 @@
import Foundation
struct FeatureControlRawFlag: Codable, Equatable {
let enabled: Bool
let variant: String
let payload: FeatureControlJSONValue?
let reason: String?
}
enum FeatureControlJSONValue: Codable, Equatable {
case string(String)
case number(Double)
case bool(Bool)
case object([String: FeatureControlJSONValue])
case array([FeatureControlJSONValue])
case null
init(from decoder: Decoder) throws {
let container = try decoder.singleValueContainer()
if container.decodeNil() {
self = .null
} else if let value = try? container.decode(Bool.self) {
self = .bool(value)
} else if let value = try? container.decode(Double.self) {
self = .number(value)
} else if let value = try? container.decode(String.self) {
self = .string(value)
} else if let value = try? container.decode([String: FeatureControlJSONValue].self) {
self = .object(value)
} else if let value = try? container.decode([FeatureControlJSONValue].self) {
self = .array(value)
} else {
self = .null
}
}
func encode(to encoder: Encoder) throws {
var container = encoder.singleValueContainer()
switch self {
case .string(let value): try container.encode(value)
case .number(let value): try container.encode(value)
case .bool(let value): try container.encode(value)
case .object(let value): try container.encode(value)
case .array(let value): try container.encode(value)
case .null: try container.encodeNil()
}
}
}
private struct FeatureControlBootstrapRequest: Codable {
struct Context: Codable {
let subjectType: String
let subjectId: String
let storeId: String?
let platform: String
let appVersion: String
let attributes: [String: String]
}
let environment: String
let keys: [String]
let context: Context
}
private struct FeatureControlBootstrapResponse: Codable {
let ok: Bool
let source: String?
let configVersion: Int
let evaluatedAt: String?
let flags: [String: FeatureFlagValue]
let raw: [String: FeatureControlRawFlag]
}
private struct FeatureControlExposureRequest: Codable {
struct Event: Codable {
let featureKey: String
let variant: String
let subjectType: String
let storeId: String?
}
let events: [Event]
}
private struct FeatureControlCacheEntry: Codable {
let expiresAtUnixMs: Int64
let snapshot: FeatureFlagsState
}
struct FeatureControlEvaluationContext {
let subjectType: String
let subjectId: String
let storeId: String?
let attributes: [String: String]
}
@MainActor
final class FeatureControlService {
static let shared = FeatureControlService()
private let session: URLSession
private let cacheTTL: TimeInterval
private let decoder = JSONDecoder()
private let encoder = JSONEncoder()
private let userDefaults: UserDefaults
private let defaultsPrefix = "feature-control.cache.v1."
init(
session: URLSession = .shared,
cacheTTL: TimeInterval = 60,
userDefaults: UserDefaults = .standard
) {
self.session = session
self.cacheTTL = cacheTTL
self.userDefaults = userDefaults
}
func evaluate(
context: FeatureControlEvaluationContext,
jwt: String?,
forceRefresh: Bool = false
) async -> FeatureFlagsState {
let key = storageKey(for: context)
if forceRefresh == false, let cached = loadFromCache(storageKey: key) {
return cached
}
let requestBody = FeatureControlBootstrapRequest(
environment: ApiConfig.featureControlEnvironment,
keys: featureKeys(),
context: .init(
subjectType: context.subjectType,
subjectId: context.subjectId,
storeId: context.storeId,
platform: platformName(),
appVersion: appVersion(),
attributes: context.attributes
)
)
do {
let response = try await performBootstrapRequest(body: requestBody, jwt: jwt)
let snapshot = FeatureFlagsState(
configVersion: response.configVersion,
evaluatedAt: response.evaluatedAt,
source: response.source ?? "live",
values: response.flags,
raw: response.raw
)
saveToCache(snapshot: snapshot, storageKey: key)
return snapshot
} catch {
if let cached = loadFromCache(storageKey: key) {
return FeatureFlagsState(
configVersion: cached.configVersion,
evaluatedAt: cached.evaluatedAt,
source: "cache_fallback",
values: cached.values,
raw: cached.raw
)
}
return FeatureFlagsState(source: "defaults")
}
}
func sendExposureEvents(snapshot: FeatureFlagsState, context: FeatureControlEvaluationContext, jwt: String?) async {
guard snapshot.raw.isEmpty == false else { return }
let events = snapshot.raw.compactMap { entry -> FeatureControlExposureRequest.Event? in
let key = entry.key
let value = entry.value
guard value.enabled || value.variant.lowercased() != "off" else { return nil }
return .init(
featureKey: key,
variant: value.variant,
subjectType: context.subjectType,
storeId: context.storeId
)
}
guard events.isEmpty == false else { return }
let batched = Array(events.prefix(100))
let payload = FeatureControlExposureRequest(events: batched)
guard let body = try? encoder.encode(payload) else { return }
var request = URLRequest(url: ApiConfig.featureControlBffURL.appendingPathComponent("/feature-control/telemetry/exposure"))
request.httpMethod = "POST"
request.httpBody = body
request.timeoutInterval = 3
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.setValue("application/json", forHTTPHeaderField: "Accept")
if let jwt, jwt.isEmpty == false {
request.setValue("Bearer \(jwt)", forHTTPHeaderField: "Authorization")
}
_ = try? await session.data(for: request)
}
private func performBootstrapRequest(body: FeatureControlBootstrapRequest, jwt: String?) async throws -> FeatureControlBootstrapResponse {
var request = URLRequest(url: ApiConfig.featureControlBffURL.appendingPathComponent("/feature-control/bootstrap"))
request.httpMethod = "POST"
request.httpBody = try encoder.encode(body)
request.timeoutInterval = 3
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.setValue("application/json", forHTTPHeaderField: "Accept")
if let jwt, jwt.isEmpty == false {
request.setValue("Bearer \(jwt)", forHTTPHeaderField: "Authorization")
}
let (data, response) = try await session.data(for: request)
guard let http = response as? HTTPURLResponse, (200...299).contains(http.statusCode) else {
throw NetworkError.invalidResponse
}
return try decoder.decode(FeatureControlBootstrapResponse.self, from: data)
}
private func storageKey(for context: FeatureControlEvaluationContext) -> String {
let tokens = [
ApiConfig.featureControlEnvironment,
context.subjectType,
context.subjectId,
context.storeId ?? "none",
platformName(),
appVersion(),
featureKeys().joined(separator: "|")
]
let base = tokens.joined(separator: "::")
.lowercased()
.replacingOccurrences(of: " ", with: "_")
return defaultsPrefix + base
}
private func saveToCache(snapshot: FeatureFlagsState, storageKey: String) {
let expiresAt = Int64((Date().timeIntervalSince1970 + cacheTTL) * 1000)
let entry = FeatureControlCacheEntry(expiresAtUnixMs: expiresAt, snapshot: snapshot)
guard let data = try? encoder.encode(entry) else { return }
userDefaults.set(data, forKey: storageKey)
}
private func loadFromCache(storageKey: String) -> FeatureFlagsState? {
guard let data = userDefaults.data(forKey: storageKey),
let entry = try? decoder.decode(FeatureControlCacheEntry.self, from: data) else {
return nil
}
let now = Int64(Date().timeIntervalSince1970 * 1000)
guard entry.expiresAtUnixMs > now else {
userDefaults.removeObject(forKey: storageKey)
return nil
}
return entry.snapshot
}
private func featureKeys() -> [String] {
let raw = ProcessInfo.processInfo.environment["FEATURE_CONTROL_KEYS"] ?? "at.city.aguai,at.promo,at.ios.only,at.android.only,at.cupons"
let items = raw
.split(separator: ",")
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { $0.isEmpty == false }
return items.isEmpty ? ["at.ios.only"] : items
}
private func platformName() -> String {
return "ios"
}
private func appVersion() -> String {
let version = (Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String)?
.trimmingCharacters(in: .whitespacesAndNewlines)
if let version, version.isEmpty == false {
return version
}
return "0.0.0"
}
}

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

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

View File

@@ -0,0 +1,44 @@
import Foundation
enum ImageSourceResolver {
static func resolve(_ raw: String?) -> String? {
guard var normalized = raw?.trimmingCharacters(in: .whitespacesAndNewlines),
normalized.isEmpty == false else { return nil }
normalized = normalized.replacingOccurrences(of: "\\/", with: "/")
let lower = normalized.lowercased()
if lower.hasPrefix("http://") || lower.hasPrefix("https://") || lower.hasPrefix("data:image") {
return normalized
}
// if let base64DataURL = normalizedBase64DataURL(normalized) {
// return base64DataURL
// }
let base = ApiConfig.baseURL.absoluteString.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
let path = normalized.hasPrefix("/") ? normalized : "/\(normalized)"
return "\(base)\(path)"
}
private static func normalizedBase64DataURL(_ raw: String) -> String? {
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
guard trimmed.isEmpty == false else { return nil }
let payload: String
if let marker = trimmed.range(of: "base64,", options: [.caseInsensitive]) {
payload = String(trimmed[marker.upperBound...])
} else {
payload = trimmed
}
let sanitized = payload
.replacingOccurrences(of: "\n", with: "")
.replacingOccurrences(of: "\r", with: "")
.replacingOccurrences(of: " ", with: "")
guard sanitized.count >= 64 else { return nil }
guard Data(base64Encoded: sanitized, options: [.ignoreUnknownCharacters]) != nil else { return nil }
return "data:image/png;base64,\(sanitized)"
}
}

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

View File

@@ -0,0 +1,193 @@
import Foundation
#if os(iOS)
import CoreLocation
/// The subset of `CLLocationManager` `LocationService` needs lets tests
/// substitute a fake instead of touching real hardware/OS permission state.
@MainActor
protocol LocationManaging: AnyObject {
var locationManagingDelegate: CLLocationManagerDelegate? { get set }
var desiredAccuracy: CLLocationAccuracy { get set }
var authorizationStatus: CLAuthorizationStatus { get }
var location: CLLocation? { get }
func requestWhenInUseAuthorization()
func requestLocation()
}
extension CLLocationManager: LocationManaging {
var locationManagingDelegate: CLLocationManagerDelegate? {
get { delegate }
set { delegate = newValue }
}
}
#endif
@MainActor
final class LocationService: NSObject {
typealias LocationResult = Result<(Double, Double), LocationError>
static let shared = LocationService()
#if os(iOS)
enum LocationError: Error {
case servicesDisabled
case denied
case unavailable
}
#else
enum LocationError: Error {
case denied
case unavailable
}
#endif
#if os(iOS)
private let manager: LocationManaging
private var completion: ((LocationResult) -> Void)?
#endif
#if os(iOS)
init(manager: LocationManaging = CLLocationManager()) {
self.manager = manager
super.init()
manager.locationManagingDelegate = self
manager.desiredAccuracy = kCLLocationAccuracyHundredMeters
}
#else
override init() {
super.init()
}
#endif
func requestLocation(_ completion: @escaping (LocationResult) -> Void) {
#if os(iOS)
self.completion = completion
handleAuthorizationStatus(manager.authorizationStatus)
#else
let defaults = UserDefaults.standard
if defaults.bool(forKey: "location_permission_denied") {
completion(.failure(.denied))
return
}
guard let latRaw = defaults.string(forKey: "last_location_lat"),
let lngRaw = defaults.string(forKey: "last_location_lng"),
let lat = Double(latRaw),
let lng = Double(lngRaw) else {
completion(.failure(.unavailable))
return
}
completion(.success((lat, lng)))
#endif
}
func cachedLocation() -> (Double, Double)? {
#if os(iOS)
guard let location = manager.location else {
return nil
}
return (location.coordinate.latitude, location.coordinate.longitude)
#else
let defaults = UserDefaults.standard
guard let latRaw = defaults.string(forKey: "last_location_lat"),
let lngRaw = defaults.string(forKey: "last_location_lng"),
let lat = Double(latRaw),
let lng = Double(lngRaw) else {
return nil
}
return (lat, lng)
#endif
}
/// Forward-geocodes a street address into coordinates. Used as a fallback
/// when a saved CustomerAddress has no lat/long (e.g. the CEP lookup at
/// creation time didn't return coordinates) without this, delivery fee
/// validation silently can't distinguish that address from any other.
static func geocodeAddress(
street: String?,
number: String?,
neighborhood: String?,
city: String?,
state: String?,
zip: String?
) async -> (Double, Double)? {
#if os(iOS)
let parts = [street, number, neighborhood, city, state, zip]
.compactMap { $0?.trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { $0.isEmpty == false }
guard parts.isEmpty == false else { return nil }
let fullAddress = parts.joined(separator: ", ")
return await withCheckedContinuation { continuation in
CLGeocoder().geocodeAddressString(fullAddress) { placemarks, error in
guard error == nil, let coordinate = placemarks?.first?.location?.coordinate else {
continuation.resume(returning: nil)
return
}
continuation.resume(returning: (coordinate.latitude, coordinate.longitude))
}
}
#else
return nil
#endif
}
func requestLocationAsync(timeoutSeconds: TimeInterval = 8) async -> (Double, Double)? {
await withCheckedContinuation { continuation in
var hasResumed = false
func resumeOnce(_ value: (Double, Double)?) {
guard hasResumed == false else { return }
hasResumed = true
continuation.resume(returning: value)
}
requestLocation { result in
switch result {
case .success(let coordinate):
resumeOnce(coordinate)
case .failure:
resumeOnce(nil)
}
}
DispatchQueue.main.asyncAfter(deadline: .now() + timeoutSeconds) {
resumeOnce(nil)
}
}
}
}
#if os(iOS)
extension LocationService: @preconcurrency CLLocationManagerDelegate {
func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
handleAuthorizationStatus(manager.authorizationStatus)
}
func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
guard let location = locations.first else { return }
completion?(.success((location.coordinate.latitude, location.coordinate.longitude)))
completion = nil
}
func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) {
completion?(.failure(.unavailable))
completion = nil
}
private func handleAuthorizationStatus(_ status: CLAuthorizationStatus) {
switch status {
case .notDetermined:
manager.requestWhenInUseAuthorization()
case .authorizedAlways, .authorizedWhenInUse:
manager.requestLocation()
case .denied, .restricted:
completion?(.failure(.denied))
completion = nil
@unknown default:
completion?(.failure(.unavailable))
completion = nil
}
}
}
#endif

View File

@@ -0,0 +1,250 @@
import Foundation
@MainActor
final class OrderRealtimeTracker {
// Keep realtime tracking on polling to avoid socket.io handshake failures
// on environments where websocket upgrade is not available.
private let useSocketRealtime = false
private var pollingTask: Task<Void, Never>? = nil
private var socketClient: OrderSocketClient? = nil
private var activeOrderId: String? = nil
var onOrderUpdated: ((PublicOrderResult) -> Void)?
func start(orderId: String, jwt: String?) {
stop()
activeOrderId = orderId
pollingTask = Task { @MainActor [weak self] in
guard let self else { return }
await self.runPollingLoop(orderId: orderId)
}
guard useSocketRealtime, let jwt, jwt.isEmpty == false else { return }
let socket = OrderSocketClient()
socket.onOrderUpdate = { [weak self] update in
guard let self else { return }
guard update.id == orderId else { return }
Task { @MainActor [weak self] in
guard let self else { return }
await self.fetchLatest(orderId: orderId)
}
}
socket.connect(jwt: jwt)
socketClient = socket
}
func stop() {
pollingTask?.cancel()
pollingTask = nil
socketClient?.disconnect()
socketClient = nil
activeOrderId = nil
}
private func runPollingLoop(orderId: String) async {
var elapsedSeconds = 0
while Task.isCancelled == false {
if activeOrderId != orderId { return }
let fetched = await fetchLatest(orderId: orderId)
if fetched?.isFinalStatus == true {
return
}
let delay = pollingDelay(for: elapsedSeconds)
elapsedSeconds += delay
do {
try await Task.sleep(nanoseconds: UInt64(delay) * 1_000_000_000)
} catch {
return
}
}
}
private func pollingDelay(for elapsedSeconds: Int) -> Int {
if elapsedSeconds < 60 { return 3 }
if elapsedSeconds < 180 { return 5 }
return 10
}
@discardableResult
private func fetchLatest(orderId: String) async -> PublicOrderResult? {
do {
logger.debug("OrderTracking poll request orderId=\(orderId)")
let response = try await ApiService().publicOrder(orderId: orderId)
guard response.error == false, let order = response.result else {
logger.error("OrderTracking poll API error orderId=\(orderId) message=\(response.message ?? "unknown")")
return nil
}
clearPendingCartIfNeeded(for: order)
logger.info("OrderTracking poll success orderId=\(orderId) status=\(order.status ?? "nil") paymentStatus=\(order.paymentStatus ?? "nil")")
onOrderUpdated?(order)
return order
} catch {
logger.error("OrderTracking poll failure orderId=\(orderId) error=\(error.localizedDescription)")
return nil
}
}
private func clearPendingCartIfNeeded(for order: PublicOrderResult) {
guard let pendingId = SessionStateStore.loadPendingCartOrderId() else { return }
let normalizedPending = pendingId.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
if normalizedPending.isEmpty { return }
let ids = [order.id, order.realId]
.compactMap { $0?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() }
guard ids.contains(normalizedPending) else { return }
if shouldClearCart(for: order) == false { return }
SessionStateStore.clearCart()
SessionStateStore.clearPendingCartOrder()
NotificationCenter.default.post(name: .cartDidReset, object: nil)
}
private func shouldClearCart(for order: PublicOrderResult) -> Bool {
if order.isPaymentConfirmed {
return true
}
let status = (order.status ?? "").uppercased()
if status.contains("COMPLETED") || status.contains("DELIVERED") || status.contains("RECEIVED") {
return true
}
return false
}
}
final class OrderSocketClient: @unchecked Sendable {
var onOrderUpdate: ((OrderRealtimeUpdate) -> Void)?
#if os(iOS) || os(macOS)
private var task: URLSessionWebSocketTask? = nil
private let session = URLSession(configuration: .default)
private var isConnected = false
private var pendingJWT: String? = nil
#endif
func connect(jwt: String) {
#if os(iOS) || os(macOS)
disconnect()
guard let url = makeSocketURL() else { return }
let wsTask = session.webSocketTask(with: url)
wsTask.resume()
task = wsTask
pendingJWT = jwt
receiveLoop()
#else
_ = jwt
#endif
}
func disconnect() {
#if os(iOS) || os(macOS)
isConnected = false
pendingJWT = nil
task?.cancel(with: .goingAway, reason: nil)
task = nil
#endif
}
#if os(iOS) || os(macOS)
private func receiveLoop() {
guard let task else { return }
task.receive { [weak self] result in
guard let self else { return }
switch result {
case .failure:
self.disconnect()
case .success(let message):
self.handleMessage(message)
self.receiveLoop()
}
}
}
private func handleMessage(_ message: URLSessionWebSocketTask.Message) {
let text: String
switch message {
case .string(let value):
text = value
case .data(let data):
text = String(data: data, encoding: .utf8) ?? ""
@unknown default:
return
}
guard text.isEmpty == false else { return }
if text == "2" {
task?.send(.string("3")) { _ in }
return
}
if text.hasPrefix("0"), let jwt = pendingJWT {
let authPacket = "40{\"token\":\"Bearer \(jwt)\"}"
task?.send(.string(authPacket)) { _ in }
pendingJWT = nil
return
}
if text.hasPrefix("40") {
isConnected = true
return
}
guard text.hasPrefix("42") else { return }
let eventPayload = String(text.dropFirst(2))
guard let data = eventPayload.data(using: .utf8) else { return }
if let rawArray = try? JSONSerialization.jsonObject(with: data) as? [Any],
rawArray.count >= 2,
let eventName = rawArray[0] as? String,
eventName == "order_update" {
let payloadAny = rawArray[1]
guard JSONSerialization.isValidJSONObject(payloadAny),
let payloadData = try? JSONSerialization.data(withJSONObject: payloadAny),
let update = try? JSONDecoder().decode(OrderRealtimeUpdate.self, from: payloadData) else {
return
}
onOrderUpdate?(update)
return
}
// Compat: alguns servidores podem encapsular o evento como objeto.
if let rawObject = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let eventName = (rawObject["event"] as? String)?.lowercased(),
eventName == "order_update",
let payloadAny = rawObject["data"],
JSONSerialization.isValidJSONObject(payloadAny),
let payloadData = try? JSONSerialization.data(withJSONObject: payloadAny),
let update = try? JSONDecoder().decode(OrderRealtimeUpdate.self, from: payloadData) {
onOrderUpdate?(update)
}
}
private func makeSocketURL() -> URL? {
guard var components = URLComponents(url: ApiConfig.baseURL, resolvingAgainstBaseURL: false) else {
return nil
}
if components.scheme == "https" {
components.scheme = "wss"
} else {
components.scheme = "ws"
}
components.path = "/socket.io/"
components.queryItems = [
URLQueryItem(name: "EIO", value: "4"),
URLQueryItem(name: "transport", value: "websocket")
]
return components.url
}
#endif
}

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

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

View File

@@ -0,0 +1,220 @@
import Foundation
/// Client-side half of docs/api/push-notifications-integration-guide.md.
/// Owns OS permission state, APNs device-token registration, the
/// "enable notifications" action shared by the profile toggle and the
/// order-tracking fallback prompt (§2b), foreground/tap notification
/// handling (§6), and campaign open tracking (§6a).
enum PushAuthorizationState {
case authorized
case denied
case notDetermined
}
/// Posted when a tapped push resolves to a navigable `DeepLinkDestination`
/// (§6) so `ContentView` can route without this service depending on
/// `AppState`. One name for every destination, present and future see
/// `DeepLinkDestination`.
extension Notification.Name {
static let pushDeepLinkReceived = Notification.Name("pushDeepLinkReceived")
}
#if os(iOS)
import UIKit
import UserNotifications
@MainActor
final class PushNotificationCoordinator: NSObject {
static let shared = PushNotificationCoordinator()
private var deviceTokenObserver: NSObjectProtocol?
private var didBecomeDelegate = false
private override init() {}
/// Call once at app launch. Listens for the device token `PediFoodsAppDelegate`
/// posts after `registerForRemoteNotifications()` resolves, and forwards it
/// to Atomenta (§2).
func startObservingDeviceToken() {
guard deviceTokenObserver == nil else { return }
deviceTokenObserver = NotificationCenter.default.addObserver(
forName: NSNotification.Name("didRegisterForRemoteNotificationsWithDeviceToken"),
object: nil,
queue: .main
) { notification in
guard let data = notification.userInfo?["deviceToken"] as? Data else { return }
let hexToken = data.map { String(format: "%02x", $0) }.joined()
Task { await PushNotificationCoordinator.shared.sendTokenToBackend(hexToken) }
}
}
/// Call once at app launch, before the first notification could possibly
/// arrive makes this the `UNUserNotificationCenterDelegate` so foreground
/// pushes actually display (§6) and taps get routed/tracked (§6, §6a).
func becomeNotificationCenterDelegate() {
guard didBecomeDelegate == false else { return }
didBecomeDelegate = true
UNUserNotificationCenter.current().delegate = self
}
/// Re-registers silently (no OS prompt) if the user already granted
/// authorization in a previous session tokens aren't guaranteed stable
/// across launches (§2, §3.3/§4.2 of the guide). Safe to call before login.
func refreshRegistrationIfAuthorized() async {
guard await currentAuthorizationState() == .authorized else { return }
UIApplication.shared.registerForRemoteNotifications()
}
func currentAuthorizationState() async -> PushAuthorizationState {
let settings = await UNUserNotificationCenter.current().notificationSettings()
switch settings.authorizationStatus {
case .authorized, .provisional, .ephemeral:
return .authorized
case .denied:
return .denied
case .notDetermined:
return .notDetermined
@unknown default:
return .notDetermined
}
}
/// Shows the OS permission dialog only if it hasn't been answered yet.
/// Always calls `registerForRemoteNotifications()` when authorized
/// including when authorization was already granted in a past session
/// so "enable" reliably produces a fresh device token this run instead of
/// relying solely on the once-per-launch refresh.
@discardableResult
private func requestAuthorizationIfNeeded() async -> Bool {
switch await currentAuthorizationState() {
case .authorized:
UIApplication.shared.registerForRemoteNotifications()
return true
case .denied:
return false
case .notDetermined:
let granted = (try? await UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .badge, .sound])) ?? false
if granted {
UIApplication.shared.registerForRemoteNotifications()
}
return granted
}
}
/// Shared body of §2b's two touchpoints: request OS permission (if
/// undetermined), then flip Atomenta's `notificationsEnabled` flag.
/// Returns the server's authoritative post-update profile callers must
/// reflect `result.notificationsEnabled` from this, not assume `true`
/// just because the request succeeded.
func enableNotifications() async -> CustomerProfile? {
guard await requestAuthorizationIfNeeded() else { return nil }
do {
let response = try await ApiService().updateNotificationsEnabled(true)
return response.error == false ? response.result : nil
} catch {
logger.error("Failed to enable push notifications: \(error.localizedDescription)")
return nil
}
}
/// See docs/api/push-notifications-integration-guide.md §2a. Best-effort,
/// silent on failure Campaign `appVersion`/`attributes` targeting just
/// won't match this user until the next successful call. Call right after
/// login and once per app launch (covers an app update since last launch).
func syncCustomerAttributes() async {
guard DefaultTokenStore().jwt != nil else { return }
do {
_ = try await ApiService().updateCustomerAttributes(appVersion: currentAppVersion(), attributes: nil)
} catch {
logger.error("Failed to sync customer attributes: \(error.localizedDescription)")
}
}
private func currentAppVersion() -> String {
(Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String)?
.trimmingCharacters(in: .whitespacesAndNewlines) ?? "0.0.0"
}
private func sendTokenToBackend(_ hexToken: String) async {
guard DefaultTokenStore().jwt != nil else { return }
do {
_ = try await ApiService().registerPushToken(hexToken, deviceId: GuestLocationStore.shared.deviceId, deviceOS: "ios")
} catch {
logger.error("Push token registration failed: \(error.localizedDescription)")
}
}
/// §6a fire on tap only, for `type: "campaign"` pushes. Idempotent
/// server-side, so no client-side "already reported" guard needed.
private func reportCampaignOpened(campaignId: String) async {
guard DefaultTokenStore().jwt != nil else { return }
do {
_ = try await ApiService().reportPushCampaignOpened(campaignId: campaignId)
} catch {
logger.error("Failed to report campaign open: \(error.localizedDescription)")
}
}
/// §6 routes on the tapped push's `data` payload. Campaign-open
/// reporting (§6a, a side effect, not a navigation target) is decided
/// directly on `type` here; navigation is delegated to
/// `PushDeepLinkParser` and forwarded to `ContentView` via
/// `NotificationCenter` (this service has no `AppState` binding of its
/// own). A push can do both e.g. a future campaign that also sets
/// `targetScreen` reports its open *and* navigates.
fileprivate func handleTap(userInfo: [AnyHashable: Any]) {
if let type = userInfo["type"] as? String, type == "campaign",
let campaignId = userInfo["campaignId"] as? String {
Task { await reportCampaignOpened(campaignId: campaignId) }
}
guard let destination = PushDeepLinkParser.parse(userInfo) else { return }
NotificationCenter.default.post(name: .pushDeepLinkReceived, object: nil, userInfo: ["destination": destination])
}
}
/// `UNNotification.userInfo` is `[AnyHashable: Any]`, which the compiler
/// can't prove `Sendable` but it's an immutable payload handed to us
/// once by the OS, so crossing the actor boundary with it is safe in
/// practice.
private struct UncheckedSendableBox<Value>: @unchecked Sendable {
let value: Value
}
extension PushNotificationCoordinator: UNUserNotificationCenterDelegate {
/// Without a delegate, iOS silently drops push notifications while the
/// app is foregrounded this is what makes them display as a banner too.
nonisolated func userNotificationCenter(
_ center: UNUserNotificationCenter,
willPresent notification: UNNotification,
withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void
) {
completionHandler([.banner, .list, .sound, .badge])
}
nonisolated func userNotificationCenter(
_ center: UNUserNotificationCenter,
didReceive response: UNNotificationResponse,
withCompletionHandler completionHandler: @escaping () -> Void
) {
let userInfo = UncheckedSendableBox(value: response.notification.request.content.userInfo)
Task { @MainActor in
PushNotificationCoordinator.shared.handleTap(userInfo: userInfo.value)
}
completionHandler()
}
}
#else
@MainActor
final class PushNotificationCoordinator {
static let shared = PushNotificationCoordinator()
private init() {}
func startObservingDeviceToken() {}
func becomeNotificationCenterDelegate() {}
func refreshRegistrationIfAuthorized() async {}
func currentAuthorizationState() async -> PushAuthorizationState { .denied }
func enableNotifications() async -> CustomerProfile? { nil }
func syncCustomerAttributes() async {}
}
#endif

View File

@@ -0,0 +1,8 @@
import Foundation
extension Notification.Name {
static let sessionExpired = Notification.Name("SessionExpiredNotification")
static let cartDidReset = Notification.Name("CartDidResetNotification")
static let orderReviewDidSave = Notification.Name("OrderReviewDidSaveNotification")
static let appDidResume = Notification.Name("AppDidResumeNotification")
}

View File

@@ -0,0 +1,456 @@
import Foundation
private struct PersistedAddressState: Codable {
let selectedId: String?
let display: String
let latitude: Double?
let longitude: Double?
}
private struct PersistedCartAddonState: Codable {
let id: String
let name: String
let quantity: Int
let unitPrice: Double
}
private struct PersistedCartItemState: Codable {
let id: String
let productId: String
let storeId: String
let name: String
let imageURL: String?
let details: String?
let addons: [PersistedCartAddonState]
let quantity: Int
let unitPrice: Double
}
private struct PersistedCartState: Codable {
let storeId: String?
let storeName: String?
let items: [PersistedCartItemState]
let total: Double
}
private struct PersistedTrackedOrdersState: Codable {
let orders: [PublicOrderResult]
}
struct OrderReviewRecord: Codable, Identifiable, Hashable {
var id: String { orderId }
let orderId: String
let storeId: String?
let shortId: String?
let storeName: String?
let storeLogoURL: String?
let createdAt: String?
let submittedAt: String
let rating: Int
let comment: String
let orderPositiveTags: [String]?
let orderImprovementTags: [String]?
let deliverySentiment: String?
let deliveryPositiveTags: [String]?
let deliveryNegativeTags: [String]?
let appNps: Int?
let platform: String?
let editableUntil: String?
let storeReplyUntil: String?
let reviewWindowExpiresAt: String?
let storeReplyMessage: String?
let storeReplyAt: String?
}
struct OrderReviewDraftState: Codable, Hashable {
var orderId: String
var orderRate: Int
var orderComment: String
var orderPositiveTags: [String]
var orderImprovementTags: [String]
var deliverySentiment: String
var deliveryPositiveTags: [String]
var deliveryNegativeTags: [String]
var appNps: Int
var platform: String
}
private struct PersistedOrderReviewsState: Codable {
let reviews: [OrderReviewRecord]
}
enum SessionStateStore {
/// Swappable for tests (isolated `UserDefaults(suiteName:)`), defaults
/// to the real app defaults in production. `UserDefaults` is thread-safe
/// on its own; `nonisolated(unsafe)` only opts out of Swift 6's static-
/// mutable-state check for the var itself, tests set it once up front.
nonisolated(unsafe) static var defaults: UserDefaults = .standard
private static let legacyAddressKey = "session.address.state.v1"
private static let addressKeyPrefix = "session.address.state.v2."
private static let activeUserKey = "session.active.user.v1"
private static let cartKeyPrefix = "session.cart.state.v1."
private static let trackedOrdersKeyPrefix = "session.orders.tracking.v1."
private static let pendingCartOrderKeyPrefix = "session.cart.pending-order.v1."
private static let orderReviewsKeyPrefix = "session.orders.reviews.v1."
private static let orderReviewDraftKeyPrefix = "session.orders.review-draft.v1."
private static let pushOptInPromptKey = "session.push.opt-in.last-prompted.v1"
private static let pushOptInCooldown: TimeInterval = 60 * 60 * 24
/// See docs/api/push-notifications-integration-guide.md §2b avoid
/// re-prompting the order-tracking fallback alert on every screen visit.
static func shouldPromptPushOptIn() -> Bool {
guard let last = defaults.object(forKey: pushOptInPromptKey) as? Date else { return true }
return Date().timeIntervalSince(last) > pushOptInCooldown
}
static func recordPushOptInPrompted() {
defaults.set(Date(), forKey: pushOptInPromptKey)
}
static func makeUserKey(profileId: String?, email: String?) -> String? {
let id = (profileId ?? "")
.trimmingCharacters(in: .whitespacesAndNewlines)
.lowercased()
if id.isEmpty == false {
return "id:\(id)"
}
let mail = (email ?? "")
.trimmingCharacters(in: .whitespacesAndNewlines)
.lowercased()
if mail.isEmpty == false {
return "email:\(mail)"
}
return nil
}
static func setActiveUserKey(_ userKey: String?) {
let trimmed = (userKey ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed.isEmpty {
defaults.removeObject(forKey: activeUserKey)
} else {
defaults.set(trimmed, forKey: activeUserKey)
}
}
static func loadActiveUserKey() -> String? {
let value = defaults.string(forKey: activeUserKey)?
.trimmingCharacters(in: .whitespacesAndNewlines)
if let value, value.isEmpty == false {
return value
}
return nil
}
private static func addressStorageKey(for userKey: String?) -> String {
let key = (userKey ?? loadActiveUserKey() ?? "anonymous")
.trimmingCharacters(in: .whitespacesAndNewlines)
let safe = key.replacingOccurrences(of: " ", with: "_")
return addressKeyPrefix + safe
}
static func loadAddress() -> AddressState? {
let activeKey = loadActiveUserKey()
let scopedKey = addressStorageKey(for: activeKey)
if let data = defaults.data(forKey: scopedKey),
let decoded = try? JSONDecoder().decode(PersistedAddressState.self, from: data) {
return AddressState(
selectedId: decoded.selectedId,
display: decoded.display.isEmpty ? "Defina seu endereco" : decoded.display,
latitude: decoded.latitude,
longitude: decoded.longitude,
onboardingMessage: nil
)
}
// Backward-compatible fallback for data persisted before user scoping.
let anonymousKey = addressStorageKey(for: "anonymous")
if let data = defaults.data(forKey: anonymousKey),
let decoded = try? JSONDecoder().decode(PersistedAddressState.self, from: data) {
let recovered = AddressState(
selectedId: decoded.selectedId,
display: decoded.display.isEmpty ? "Defina seu endereco" : decoded.display,
latitude: decoded.latitude,
longitude: decoded.longitude,
onboardingMessage: nil
)
// Migrate anonymous cache into the current active user namespace.
if let activeKey, activeKey.isEmpty == false {
let payload = PersistedAddressState(
selectedId: recovered.selectedId,
display: recovered.display,
latitude: recovered.latitude,
longitude: recovered.longitude
)
if let migratedData = try? JSONEncoder().encode(payload) {
defaults.set(migratedData, forKey: scopedKey)
}
}
return recovered
}
guard let data = defaults.data(forKey: legacyAddressKey),
let decoded = try? JSONDecoder().decode(PersistedAddressState.self, from: data) else {
return nil
}
let migrated = AddressState(
selectedId: decoded.selectedId,
display: decoded.display.isEmpty ? "Defina seu endereco" : decoded.display,
latitude: decoded.latitude,
longitude: decoded.longitude,
onboardingMessage: nil
)
saveAddress(migrated)
defaults.removeObject(forKey: legacyAddressKey)
return migrated
}
static func saveAddress(_ state: AddressState) {
let payload = PersistedAddressState(
selectedId: state.selectedId,
display: state.display,
latitude: state.latitude,
longitude: state.longitude
)
guard let data = try? JSONEncoder().encode(payload) else { return }
defaults.set(data, forKey: addressStorageKey(for: nil))
}
static func clearAddress() {
defaults.removeObject(forKey: addressStorageKey(for: nil))
defaults.removeObject(forKey: legacyAddressKey)
}
static func clearActiveUser() {
defaults.removeObject(forKey: activeUserKey)
}
private static func cartStorageKey(for userKey: String?) -> String {
let key = (userKey ?? loadActiveUserKey() ?? "anonymous")
.trimmingCharacters(in: .whitespacesAndNewlines)
let safe = key.replacingOccurrences(of: " ", with: "_")
return cartKeyPrefix + safe
}
static func loadCart() -> CartState? {
let key = cartStorageKey(for: nil)
if let data = defaults.data(forKey: key),
let decoded = try? JSONDecoder().decode(PersistedCartState.self, from: data) {
return CartState(
storeId: decoded.storeId,
storeName: decoded.storeName,
items: decoded.items.map {
CartItemState(
id: $0.id,
productId: $0.productId,
storeId: $0.storeId,
name: $0.name,
imageURL: $0.imageURL,
details: $0.details,
addons: $0.addons.map {
CartItemAddonState(
id: $0.id,
name: $0.name,
quantity: $0.quantity,
unitPrice: $0.unitPrice
)
},
quantity: $0.quantity,
unitPrice: $0.unitPrice
)
},
total: decoded.total
)
}
return nil
}
static func saveCart(_ state: CartState) {
let payload = PersistedCartState(
storeId: state.storeId,
storeName: state.storeName,
items: state.items.map {
PersistedCartItemState(
id: $0.id,
productId: $0.productId,
storeId: $0.storeId,
name: $0.name,
imageURL: $0.imageURL,
details: $0.details,
addons: $0.addons.map {
PersistedCartAddonState(
id: $0.id,
name: $0.name,
quantity: $0.quantity,
unitPrice: $0.unitPrice
)
},
quantity: $0.quantity,
unitPrice: $0.unitPrice
)
},
total: state.total
)
guard let data = try? JSONEncoder().encode(payload) else { return }
defaults.set(data, forKey: cartStorageKey(for: nil))
}
static func clearCart() {
defaults.removeObject(forKey: cartStorageKey(for: nil))
}
private static func trackedOrdersStorageKey(for userKey: String?) -> String {
let key = (userKey ?? loadActiveUserKey() ?? "anonymous")
.trimmingCharacters(in: .whitespacesAndNewlines)
let safe = key.replacingOccurrences(of: " ", with: "_")
return trackedOrdersKeyPrefix + safe
}
private static func pendingCartOrderStorageKey(for userKey: String?) -> String {
let key = (userKey ?? loadActiveUserKey() ?? "anonymous")
.trimmingCharacters(in: .whitespacesAndNewlines)
let safe = key.replacingOccurrences(of: " ", with: "_")
return pendingCartOrderKeyPrefix + safe
}
private static func orderReviewsStorageKey(for userKey: String?) -> String {
let key = (userKey ?? loadActiveUserKey() ?? "anonymous")
.trimmingCharacters(in: .whitespacesAndNewlines)
let safe = key.replacingOccurrences(of: " ", with: "_")
return orderReviewsKeyPrefix + safe
}
private static func orderReviewDraftStorageKey(for orderId: String, userKey: String?) -> String {
let scope = (userKey ?? loadActiveUserKey() ?? "anonymous")
.trimmingCharacters(in: .whitespacesAndNewlines)
.replacingOccurrences(of: " ", with: "_")
let id = orderId
.trimmingCharacters(in: .whitespacesAndNewlines)
.lowercased()
return "\(orderReviewDraftKeyPrefix)\(scope).\(id)"
}
static func loadTrackedOrders() -> [PublicOrderResult] {
let key = trackedOrdersStorageKey(for: nil)
guard let data = defaults.data(forKey: key),
let decoded = try? JSONDecoder().decode(PersistedTrackedOrdersState.self, from: data) else {
return []
}
return decoded.orders
}
static func loadTrackedOrder(orderId: String) -> PublicOrderResult? {
loadTrackedOrders().first(where: { $0.id == orderId })
}
static func saveTrackedOrder(_ order: PublicOrderResult) {
var orders = loadTrackedOrders()
if let index = orders.firstIndex(where: { $0.id == order.id }) {
orders[index] = order
} else {
orders.insert(order, at: 0)
}
if orders.count > 60 {
orders = Array(orders.prefix(60))
}
let payload = PersistedTrackedOrdersState(orders: orders)
guard let data = try? JSONEncoder().encode(payload) else { return }
defaults.set(data, forKey: trackedOrdersStorageKey(for: nil))
}
static func clearTrackedOrders() {
defaults.removeObject(forKey: trackedOrdersStorageKey(for: nil))
}
static func savePendingCartOrderId(_ orderId: String) {
let clean = orderId.trimmingCharacters(in: .whitespacesAndNewlines)
guard clean.isEmpty == false else {
clearPendingCartOrder()
return
}
defaults.set(clean, forKey: pendingCartOrderStorageKey(for: nil))
}
static func loadPendingCartOrderId() -> String? {
let value = defaults.string(forKey: pendingCartOrderStorageKey(for: nil))?
.trimmingCharacters(in: .whitespacesAndNewlines)
if let value, value.isEmpty == false {
return value
}
return nil
}
static func clearPendingCartOrder() {
defaults.removeObject(forKey: pendingCartOrderStorageKey(for: nil))
}
static func loadOrderReviews() -> [OrderReviewRecord] {
let key = orderReviewsStorageKey(for: nil)
guard let data = defaults.data(forKey: key),
let decoded = try? JSONDecoder().decode(PersistedOrderReviewsState.self, from: data) else {
return []
}
return decoded.reviews.sorted { lhs, rhs in
lhs.submittedAt > rhs.submittedAt
}
}
static func loadOrderReview(orderId: String) -> OrderReviewRecord? {
let normalized = orderId.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
guard normalized.isEmpty == false else { return nil }
return loadOrderReviews().first { review in
review.orderId.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == normalized
}
}
static func hasOrderReview(orderId: String) -> Bool {
loadOrderReview(orderId: orderId) != nil
}
static func saveOrderReview(_ review: OrderReviewRecord) {
let cleanId = review.orderId.trimmingCharacters(in: .whitespacesAndNewlines)
guard cleanId.isEmpty == false else { return }
var reviews = loadOrderReviews()
if let index = reviews.firstIndex(where: {
$0.orderId.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == cleanId.lowercased()
}) {
reviews[index] = review
} else {
reviews.insert(review, at: 0)
}
let payload = PersistedOrderReviewsState(reviews: reviews)
guard let data = try? JSONEncoder().encode(payload) else { return }
defaults.set(data, forKey: orderReviewsStorageKey(for: nil))
}
static func loadOrderReviewDraft(orderId: String) -> OrderReviewDraftState? {
let key = orderReviewDraftStorageKey(for: orderId, userKey: nil)
guard let data = defaults.data(forKey: key),
let draft = try? JSONDecoder().decode(OrderReviewDraftState.self, from: data) else {
return nil
}
return draft
}
static func saveOrderReviewDraft(_ draft: OrderReviewDraftState) {
let key = orderReviewDraftStorageKey(for: draft.orderId, userKey: nil)
guard let data = try? JSONEncoder().encode(draft) else { return }
defaults.set(data, forKey: key)
}
static func clearOrderReviewDraft(orderId: String) {
let key = orderReviewDraftStorageKey(for: orderId, userKey: nil)
defaults.removeObject(forKey: key)
}
}

View File

@@ -0,0 +1,280 @@
import Foundation
enum StoreCatalogNormalizer {
static func sanitize(categories: [StoreCatalogCategory], storeId: String) -> [StoreCatalogCategory] {
var seenCategoryIds: Set<String> = []
return categories.enumerated().map { categoryIndex, category in
let categoryId = makeUniqueId(
rawValue: category.id,
fallback: "\(storeId)-category-\(categoryIndex)",
seenIds: &seenCategoryIds
)
let normalizedPizzaConfig = sanitize(
pizzaConfig: category.pizzaConfig,
categoryId: categoryId
)
var seenProductIds: Set<String> = []
let normalizedProducts = category.products.enumerated().map { productIndex, product in
sanitize(
product: product,
categoryId: categoryId,
productIndex: productIndex,
seenProductIds: &seenProductIds
)
}
return StoreCatalogCategory(
id: categoryId,
name: category.name,
isPizzaCategory: category.isPizzaCategory,
pizzaConfig: normalizedPizzaConfig,
products: normalizedProducts
)
}
}
static func preferredCategoryId(
from categories: [StoreCatalogCategory],
preferredId: String?
) -> String? {
guard let preferredId, preferredId.isEmpty == false else {
return categories.first?.id
}
if categories.contains(where: { $0.id == preferredId }) {
return preferredId
}
return categories.first?.id
}
private static func sanitize(
pizzaConfig: StorePizzaConfig?,
categoryId: String
) -> StorePizzaConfig? {
guard let pizzaConfig else { return nil }
var seenSizeIds: Set<String> = []
let normalizedSizes = pizzaConfig.sizes.enumerated().map { index, size in
StorePizzaSize(
id: makeUniqueId(
rawValue: size.id,
fallback: "\(categoryId)-size-\(index)",
seenIds: &seenSizeIds
),
name: size.name,
slices: size.slices,
maxFlavors: size.maxFlavors
)
}
var seenDoughIds: Set<String> = []
let normalizedDoughs = pizzaConfig.doughs.enumerated().map { index, dough in
StorePizzaDough(
id: makeUniqueId(
rawValue: dough.id,
fallback: "\(categoryId)-dough-\(index)",
seenIds: &seenDoughIds
),
name: dough.name,
active: dough.active
)
}
var seenCrustIds: Set<String> = []
let normalizedCrusts = pizzaConfig.crusts.enumerated().map { index, crust in
StorePizzaCrust(
id: makeUniqueId(
rawValue: crust.id,
fallback: "\(categoryId)-crust-\(index)",
seenIds: &seenCrustIds
),
name: crust.name,
active: crust.active,
priceModifier: crust.priceModifier
)
}
return StorePizzaConfig(
sizes: normalizedSizes,
doughs: normalizedDoughs,
crusts: normalizedCrusts
)
}
private static func sanitize(
product: StoreCatalogProduct,
categoryId: String,
productIndex: Int,
seenProductIds: inout Set<String>
) -> StoreCatalogProduct {
let productId = makeUniqueId(
rawValue: product.id,
fallback: "\(categoryId)-product-\(productIndex)",
seenIds: &seenProductIds
)
var seenGroupIds: Set<String> = []
var seenAddonItemIds: Set<String> = []
let normalizedAddonGroups = product.addonGroups.enumerated().map { groupIndex, group in
let groupId = makeUniqueId(
rawValue: group.id,
fallback: "\(productId)-group-\(groupIndex)",
seenIds: &seenGroupIds
)
let normalizedItems = group.items.enumerated().map { itemIndex, item in
StoreAddonItem(
id: makeUniqueId(
rawValue: item.id,
fallback: "\(groupId)-item-\(itemIndex)",
seenIds: &seenAddonItemIds
),
name: item.name,
price: item.price
)
}
return StoreAddonGroup(
id: groupId,
name: group.name,
minSelectors: group.minSelectors,
maxSelectors: group.maxSelectors,
items: normalizedItems
)
}
return StoreCatalogProduct(
id: productId,
type: product.type,
name: product.name,
description: product.description,
image: product.image,
price: product.price,
originalPrice: product.originalPrice,
pizzaPrices: product.pizzaPrices,
addonGroups: normalizedAddonGroups
)
}
private static func makeUniqueId(
rawValue: String,
fallback: String,
seenIds: inout Set<String>
) -> String {
let trimmedRawValue = rawValue.trimmingCharacters(in: .whitespacesAndNewlines)
let baseId = trimmedRawValue.isEmpty ? fallback : trimmedRawValue
if seenIds.contains(baseId) == false {
seenIds.insert(baseId)
return baseId
}
var suffix = 1
while true {
let candidate = "\(baseId)-\(suffix)"
if seenIds.contains(candidate) == false {
seenIds.insert(candidate)
return candidate
}
suffix += 1
}
}
}
extension StoreCatalogCategory {
init(
id: String,
name: String,
isPizzaCategory: Bool,
pizzaConfig: StorePizzaConfig?,
products: [StoreCatalogProduct]
) {
self.id = id
self.name = name
self.isPizzaCategory = isPizzaCategory
self.pizzaConfig = pizzaConfig
self.products = products
}
}
extension StoreCatalogProduct {
init(
id: String,
type: String?,
name: String,
description: String?,
image: String?,
price: Double?,
originalPrice: Double?,
pizzaPrices: [String: Double],
addonGroups: [StoreAddonGroup]
) {
self.id = id
self.type = type
self.name = name
self.description = description
self.image = image
self.price = price
self.originalPrice = originalPrice
self.pizzaPrices = pizzaPrices
self.addonGroups = addonGroups
}
}
extension StoreAddonGroup {
init(
id: String,
name: String,
minSelectors: Int?,
maxSelectors: Int?,
items: [StoreAddonItem]
) {
self.id = id
self.name = name
self.minSelectors = minSelectors
self.maxSelectors = maxSelectors
self.items = items
}
}
extension StoreAddonItem {
init(
id: String,
name: String,
price: Double?
) {
self.id = id
self.name = name
self.price = price
}
}
extension StorePizzaConfig {
init(
sizes: [StorePizzaSize],
doughs: [StorePizzaDough],
crusts: [StorePizzaCrust]
) {
self.sizes = sizes
self.doughs = doughs
self.crusts = crusts
}
}
extension StorePizzaCrust {
init(
id: String,
name: String?,
active: Bool?,
priceModifier: Double?
) {
self.id = id
self.name = name
self.active = active
self.priceModifier = priceModifier
}
}

View File

@@ -0,0 +1,40 @@
import Foundation
protocol TokenStore: AnyObject {
var jwt: String? { get set }
func clear()
}
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 = KeychainStore.load(service: serviceName, key: key) {
return keychainValue
}
#endif
return defaults.string(forKey: key)
}
set {
#if os(iOS)
if let newValue {
KeychainStore.save(newValue, service: serviceName, key: key)
} else {
KeychainStore.delete(service: serviceName, key: key)
}
#endif
defaults.set(newValue, forKey: key)
}
}
func clear() {
#if os(iOS)
KeychainStore.delete(service: serviceName, key: key)
#endif
defaults.removeObject(forKey: key)
}
}

View File

@@ -0,0 +1,291 @@
import Foundation
struct AppState {
var session = SessionState()
var profile = ProfileState()
var cart = CartState()
var address = AddressState()
var favorites = FavoritesState()
var featureFlags = FeatureFlagsState()
var homeFilters = HomeFiltersState()
var activeModal: AppModal? = nil
var shouldNavigateToOrders: Bool = false
/// Set when a push tap (§6 of the push notifications guide) targets a
/// specific order consumed once by `OrdersView`, which routes to it via
/// `OrderEntryDestinationView` and clears it.
var pendingOrderDeepLink: OrderRouteContext? = nil
}
enum FeatureFlagValue: Codable, Equatable {
case boolean(Bool)
case text(String)
init(from decoder: Decoder) throws {
let container = try decoder.singleValueContainer()
if let boolValue = try? container.decode(Bool.self) {
self = .boolean(boolValue)
return
}
if let stringValue = try? container.decode(String.self) {
self = .text(stringValue)
return
}
if let intValue = try? container.decode(Int.self) {
self = .text(String(intValue))
return
}
if let doubleValue = try? container.decode(Double.self) {
self = .text(String(doubleValue))
return
}
self = .text("off")
}
func encode(to encoder: Encoder) throws {
var container = encoder.singleValueContainer()
switch self {
case .boolean(let value):
try container.encode(value)
case .text(let value):
try container.encode(value)
}
}
var boolValue: Bool {
switch self {
case .boolean(let value):
return value
case .text(let value):
return value.lowercased() == "on" || value.lowercased() == "true"
}
}
}
struct FeatureFlagsState: Codable, Equatable {
var configVersion: Int = 0
var evaluatedAt: String? = nil
var source: String = "default"
var values: [String: FeatureFlagValue] = [:]
var raw: [String: FeatureControlRawFlag] = [:]
func isEnabled(_ key: String, default defaultValue: Bool = false) -> Bool {
if let rawValue = raw[key] {
return rawValue.enabled || rawValue.variant.lowercased() == "on"
}
if let mapped = values[key] {
return mapped.boolValue
}
return defaultValue
}
}
enum AppModal: String, Identifiable {
case addressPicker
case filters
var id: String { rawValue }
}
struct SessionState {
var isAuthenticated: Bool = false
var jwt: String? = nil
}
struct ProfileState {
var id: String? = nil
var name: String = ""
var email: String = ""
var phone: String = ""
var profilePicture: String = ""
var cpf: String = ""
var notificationsEnabled: Bool = false
var faceIdEnabled: Bool = false
}
struct AddressState {
var selectedId: String? = nil
var display: String = "Defina seu endereco"
var latitude: Double? = nil
var longitude: Double? = nil
var onboardingMessage: String? = nil
}
struct FavoritesState {
var storeIds: Set<String> = []
}
enum HomeSortOption: String, CaseIterable, Identifiable {
case relevance
case rating
case deliveryTime
case price
var id: String { rawValue }
var title: String {
switch self {
case .relevance: return "Relevância"
case .rating: return "Avaliação"
case .deliveryTime: return "Tempo de entrega"
case .price: return "Preço"
}
}
var icon: String {
switch self {
case .relevance: return "checkmark.seal.fill"
case .rating: return "star.fill"
case .deliveryTime: return "clock.fill"
case .price: return "dollarsign"
}
}
}
enum HomePriceTier: String, CaseIterable, Identifiable {
case low = "$"
case medium = "$$"
case high = "$$$"
case veryHigh = "$$$$"
var id: String { rawValue }
}
struct HomeFiltersState {
var sortOption: HomeSortOption = .relevance
var selectedCategories: Set<String> = []
var selectedPriceTier: HomePriceTier? = nil
var maxDistanceKm: Double = 10
var availableCategories: [String] = []
mutating func reset() {
sortOption = .relevance
selectedCategories = []
selectedPriceTier = nil
maxDistanceKm = 10
}
}
struct CartState {
var storeId: String? = nil
var storeName: String? = nil
var items: [CartItemState] = []
var total: Double = 0
}
struct CartItemState: Identifiable {
let id: String
var productId: String
var storeId: String
var name: String
var imageURL: String? = nil
var details: String? = nil
var choices: [String]? = nil
var addons: [CartItemAddonState] = []
var quantity: Int
var unitPrice: Double
}
struct CartItemAddonState: Identifiable, Hashable {
let id: String
var name: String
var quantity: Int
var unitPrice: Double
}
extension CartState {
var totalItems: Int {
items.reduce(0) { $0 + $1.quantity }
}
mutating func recalculateTotal() {
total = items.reduce(0) { $0 + (Double($1.quantity) * $1.unitPrice) }
}
mutating func clear() {
storeId = nil
storeName = nil
items = []
total = 0
SessionStateStore.clearCart()
}
mutating func add(item: CartItemState) {
if let index = items.firstIndex(where: { $0.id == item.id }) {
items[index].quantity += item.quantity
} else {
items.append(item)
}
recalculateTotal()
SessionStateStore.saveCart(self)
}
mutating func set(item: CartItemState) {
if let index = items.firstIndex(where: { $0.id == item.id }) {
if item.quantity <= 0 {
items.remove(at: index)
} else {
items[index] = item
}
} else if item.quantity > 0 {
items.append(item)
}
if items.isEmpty {
storeId = nil
storeName = nil
}
recalculateTotal()
if items.isEmpty {
SessionStateStore.clearCart()
} else {
SessionStateStore.saveCart(self)
}
}
mutating func increment(itemId: String) {
guard let index = items.firstIndex(where: { $0.id == itemId }) else { return }
items[index].quantity += 1
recalculateTotal()
SessionStateStore.saveCart(self)
}
mutating func decrement(itemId: String) {
guard let index = items.firstIndex(where: { $0.id == itemId }) else { return }
items[index].quantity -= 1
if items[index].quantity <= 0 {
items.remove(at: index)
}
if items.isEmpty {
storeId = nil
storeName = nil
}
recalculateTotal()
if items.isEmpty {
SessionStateStore.clearCart()
} else {
SessionStateStore.saveCart(self)
}
}
func toOrderItemsPayload() -> [CreateOrderItemPayload] {
items.map { item in
CreateOrderItemPayload(
productId: item.productId,
name: item.name,
qty: item.quantity,
price: item.unitPrice,
addons: item.addons
.filter { $0.quantity > 0 }
.map {
CreateOrderAddonPayload(
addonId: $0.id,
name: $0.name,
qty: $0.quantity,
price: $0.unitPrice
)
},
choices: item.choices?.isEmpty == false ? item.choices : nil
)
}
}
}

View File

@@ -0,0 +1,94 @@
import Foundation
import SwiftUI
extension Notification.Name {
static let snackbarDidChange = Notification.Name("snackbarDidChange")
}
@MainActor
final class SnackbarCenter: ObservableObject {
static let shared = SnackbarCenter()
@Published var current: SnackbarMessage?
private var dismissTask: Task<Void, Never>?
func show(
title: String,
style: SnackbarStyle = .info,
icon: String? = nil,
duration: TimeInterval = 3.5,
isPersistent: Bool = false,
action: (() -> Void)? = nil
) {
dismissTask?.cancel()
dismissTask = nil
current = SnackbarMessage(
title: title,
style: style,
iconSystemName: icon,
duration: duration,
isPersistent: isPersistent,
action: action
)
guard isPersistent == false else { return }
dismissTask = Task { [weak self] in
let nanos = UInt64(max(0.2, duration) * 1_000_000_000)
try? await Task.sleep(nanoseconds: nanos)
guard !Task.isCancelled else { return }
self?.dismiss(animated: true)
}
}
func handleTap() {
guard current?.isPersistent != true else { return }
let action = current?.action
dismiss(animated: true)
action?()
}
func dismiss(animated: Bool) {
dismissTask?.cancel()
dismissTask = nil
if animated {
withAnimation(.spring(response: 0.3, dampingFraction: 0.9)) {
current = nil
}
} else {
current = nil
}
}
func dismissPersistent() {
guard current?.isPersistent == true else { return }
dismiss(animated: true)
}
}
enum SnackbarStyle: Sendable, Equatable {
case info
case success
case warning
case error
var backgroundColor: Color {
switch self {
case .info: return Color(hex: "#3B93F7")
case .success: return Color(hex: "#2E7D32")
case .warning: return Color(hex: "#C77700")
case .error: return Color(hex: "#C62828")
}
}
}
struct SnackbarMessage: Identifiable {
let id = UUID()
let title: String
let style: SnackbarStyle
let iconSystemName: String?
let duration: TimeInterval
let isPersistent: Bool
let action: (() -> Void)?
}

View File

@@ -0,0 +1,97 @@
import Foundation
import SwiftUI
import CoreGraphics
import UIKit
extension UIDevice {
static var appSafeAreaTop: CGFloat {
let scenes = UIApplication.shared.connectedScenes
.compactMap { $0 as? UIWindowScene }
let windows = scenes.flatMap { $0.windows }
if let maxTop = windows.map({ $0.safeAreaInsets.top }).max(), maxTop > 0 {
return maxTop
}
if let fallbackMaxTop = UIApplication.shared.windows.map({ $0.safeAreaInsets.top }).max(), fallbackMaxTop > 0 {
return fallbackMaxTop
}
return 0
}
}
func appReadClipboardText() -> String? {
UIPasteboard.general.string
}
func appWriteClipboardText(_ value: String) {
UIPasteboard.general.string = value
}
extension View {
@ViewBuilder
func appInlineNavigationTitle() -> some View {
self
.navigationBarTitleDisplayMode(.inline)
.modifier(AppRoundedBackButtonModifier())
}
@ViewBuilder
func appHiddenNavigationBar() -> some View {
self
.toolbar(.hidden, for: .navigationBar)
.toolbarBackground(.hidden, for: .navigationBar)
}
@ViewBuilder
func appTopBarTrailingToolbar<Content: View>(@ViewBuilder content: () -> Content) -> some View {
self.toolbar {
ToolbarItem(placement: .topBarTrailing) {
content()
}
}
}
@ViewBuilder
func appContentShape<S: Shape>(_ shape: S) -> some View {
self.contentShape(shape)
}
@ViewBuilder
func appBottomSafeAreaInset<Content: View>(@ViewBuilder content: () -> Content) -> some View {
self.safeAreaInset(edge: .bottom) {
content()
}
}
@ViewBuilder
func appLayoutPriority(_ value: Double) -> some View {
self.layoutPriority(value)
}
@ViewBuilder
func appNamedCoordinateSpace(_ name: String) -> some View {
self.coordinateSpace(name: name)
}
}
private struct AppRoundedBackButtonModifier: ViewModifier {
@Environment(\.dismiss) private var dismiss
func body(content: Content) -> some View {
content
.navigationBarBackButtonHidden(true)
.toolbar {
ToolbarItem(placement: .topBarLeading) {
Button(action: { dismiss() }) {
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)
}
}
}
}

View File

@@ -0,0 +1,47 @@
import SwiftUI
enum Route: Hashable {
case terms, policy
case registration, loginEmail
case otp(email: String, phoneNumber: String)
}
struct AuthFlowView: View {
@Binding var root: RootFlow
@Binding var selectedTab: MainTab
let tokenStore: TokenStore
@Binding var appState: AppState
var shouldPrepareLoginEntry: Bool = false
var authEntryAnimationToken: Int = 0
@State var path: [Route] = []
var body: some View {
NavigationStack(path: $path) {
LoginView(
root: $root,
selectedTab: $selectedTab,
tokenStore: tokenStore,
appState: $appState,
shouldPrepareEntryAnimation: shouldPrepareLoginEntry,
authEntryAnimationToken: authEntryAnimationToken
) { route in
path.append(route)
}
.navigationDestination(for: Route.self) { route in
switch route {
case .terms:
TermsOfUseView()
case .policy:
PrivacyPolicyView()
case .registration:
RegistrationView(root: $root, selectedTab: $selectedTab, tokenStore: tokenStore, appState: $appState, navigate: { route in path.append(route) })
case .loginEmail:
LoginEmailView(root: $root, selectedTab: $selectedTab, tokenStore: tokenStore, appState: $appState, navigate: { route in path.append(route) })
case .otp(let email, let phoneNumber):
OtpView(email: email, phoneNumber: phoneNumber, root: $root, selectedTab: $selectedTab, tokenStore: tokenStore, appState: $appState)
}
}
}
}
}

View File

@@ -0,0 +1,88 @@
import Foundation
func formatPhoneBR(_ input: String) -> String {
let digits = input.filter(\.isNumber)
let limited = String(digits.prefix(11))
let count = limited.count
guard count > 0 else { return "" }
if count <= 2 {
return "(\(limited)"
}
let area = String(limited.prefix(2))
let remainder = String(limited.dropFirst(2))
if count <= 7 {
return "(\(area)) \(remainder)"
}
let firstPart = String(remainder.prefix(5))
let secondPart = String(remainder.dropFirst(5))
return "(\(area)) \(firstPart)-\(secondPart)"
}
func normalizePhoneNumberForAPI(_ input: String) -> String {
let digitsOnly = input.filter(\.isNumber)
if digitsOnly.count < 10 {
return ""
}
if digitsOnly.hasPrefix("55") {
return "+\(digitsOnly)"
}
return "+55\(digitsOnly)"
}
func userFacingAuthErrorMessage(_ error: Error) -> String {
if let serviceError = error as? ApiServiceError {
switch serviceError {
case .sessionExpired(let message):
if let message, !message.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
return message
}
return "Sua sessão expirou. Faça login novamente."
}
}
if let networkError = error as? NetworkError {
switch networkError {
case .unauthorized(let message):
if let message, !message.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
return message
}
return "Seu acesso expirou. Solicite um novo código para continuar."
case .httpError(let code, let message):
if let message, !message.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
return message
}
switch code {
case 400:
return "Não foi possível validar seus dados. Revise as informações e tente novamente."
case 401, 403:
return "Seu acesso expirou. Solicite um novo código para continuar."
case 404:
return "Não encontramos seu cadastro com os dados informados."
case 429:
return "Muitas tentativas em sequência. Aguarde um instante e tente novamente."
case 500...599:
return "Nossos servidores estão instáveis no momento. Tente novamente em alguns minutos."
default:
return "Não foi possível concluir a operação agora. Tente novamente."
}
case .rateLimited:
return "Muitas tentativas em sequência. Aguarde um instante e tente novamente."
case .transportError:
return "Não foi possível se conectar ao servidor. Tente novamente."
case .invalidURL, .invalidResponse, .decodeError:
return "Ocorreu uma instabilidade ao processar sua solicitação. Tente novamente."
case .cancelled:
return "Cancelado"
case .timedOut:
return "O servidor demorou demais para responder. Tente novamente."
}
}
return "Não foi possível concluir a operação. Tente novamente."
}

View File

@@ -0,0 +1,131 @@
import SwiftUI
import WebKit
private struct RemotePDFView: View {
let url: URL
@Binding var isLoading: Bool
@Binding var hasError: Bool
var body: some View {
RemotePDFRepresentable(url: url, isLoading: $isLoading, hasError: $hasError)
}
}
private struct RemotePDFRepresentable: UIViewRepresentable {
let url: URL
@Binding var isLoading: Bool
@Binding var hasError: Bool
func makeUIView(context: Context) -> WKWebView {
let webView = WKWebView()
webView.navigationDelegate = context.coordinator
webView.load(URLRequest(url: url))
return webView
}
func updateUIView(_ uiView: WKWebView, context: Context) {}
func makeCoordinator() -> RemotePDFCoordinator {
RemotePDFCoordinator(isLoading: $isLoading, hasError: $hasError)
}
}
private final class RemotePDFCoordinator: NSObject, WKNavigationDelegate {
@Binding var isLoading: Bool
@Binding var hasError: Bool
init(isLoading: Binding<Bool>, hasError: Binding<Bool>) {
self._isLoading = isLoading
self._hasError = hasError
}
func webView(_ webView: WKWebView, didStartProvisionalNavigation navigation: WKNavigation!) {
isLoading = true
hasError = false
}
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
isLoading = false
}
func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) {
isLoading = false
hasError = true
}
func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) {
isLoading = false
hasError = true
}
}
private struct LegalDocumentScreen: View {
let title: String
let headerTitle: String
let document: LegalDocument
@Environment(\.colorScheme) var colorScheme
@Environment(\.dismiss) var dismiss
@State private var isLoading = true
@State private var hasError = false
var body: some View {
VStack(spacing: 0) {
screenHeader
.padding(24)
ZStack {
RemotePDFView(url: document.url, isLoading: $isLoading, hasError: $hasError)
if isLoading {
ProgressView()
}
if hasError {
VStack(spacing: 8) {
Text("Não foi possível carregar \(title.lowercased()).")
.font(AppTypography.body)
.foregroundStyle(AppColors.textPrimary)
.multilineTextAlignment(.center)
}
.padding(24)
}
}
}
.background((colorScheme == .dark ? Color.black : AppColors.backgroundLight).ignoresSafeArea())
.appHiddenNavigationBar()
.navigationBarBackButtonHidden(true)
}
private var screenHeader: some View {
ZStack {
Text(headerTitle)
.font(AppTypography.heading2)
.foregroundStyle(AppColors.textPrimary)
HStack {
Button(action: { dismiss() }) {
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()
}
}
}
}
struct TermsOfUseView: View {
var body: some View {
LegalDocumentScreen(title: "os Termos de Uso", headerTitle: "Termos de Uso", document: .terms)
}
}
struct PrivacyPolicyView: View {
var body: some View {
LegalDocumentScreen(title: "a Política de Privacidade", headerTitle: "Privacidade", document: .privacyPolicy)
}
}

View File

@@ -0,0 +1,146 @@
import SwiftUI
struct LoginEmailView: View {
@Binding var root: RootFlow
@Binding var selectedTab: MainTab
let tokenStore: TokenStore
@Binding var appState: AppState
let navigate: (Route) -> Void
@State var email = ""
@State var phone = ""
@State var isLoading = false
@State var errorMessage: String?
@Environment(\.dismiss) var dismiss
@Environment(\.colorScheme) var colorScheme
@ViewBuilder private var logoImage: some View {
SwiftUI.Image("pedifoods")
.resizable()
}
var body: some View {
ZStack {
(colorScheme == .dark ? Color.black : AppColors.backgroundLight).ignoresSafeArea()
VStack(spacing: 0) {
logoImage
.scaledToFit()
.frame(width: 120, height: 120)
Text("Boas-vindas!")
.font(AppTypography.heading1)
.foregroundStyle(colorScheme == .dark ? Color.white : AppColors.textPrimary)
.padding(Edge.Set.top, 8)
.padding(.bottom, 16)
VStack(spacing: 16) {
VStack(alignment: .leading, spacing: 16) {
LoginField(icon: "envelope", placeholder: "seu@email.com", keyboardType: .emailAddress, text: $email)
LoginField(icon: "phone", placeholder: "(00) 00000-0000", keyboardType: .numberPad, text: $phone)
.onChange(of: phone) { _, newValue in
let masked = formatPhoneBR(newValue)
if masked != newValue {
phone = masked
}
}
}
}
.padding(.horizontal, 24)
.padding(.bottom, 16)
PrimaryButton(title: "Receber Código", image: Image(systemName: "arrow.right")) {
requestOtp()
}
.padding(.horizontal, 24)
.tint(AppColors.tertiary)
.disabled(isLoading || email.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || normalizePhoneNumberForAPI(phone).isEmpty)
.opacity((isLoading || email.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || normalizePhoneNumberForAPI(phone).isEmpty) ? 0.6 : 1.0)
Text("Enviaremos um código de verificação por SMS \ne E-mail para confirmar seu acesso.")
.font(.caption)
.foregroundStyle(Color.gray)
.multilineTextAlignment(.center)
.padding(.horizontal, 40)
.padding([.top, .bottom], 16)
HStack(spacing: 6) {
Text("Novo por aqui?")
.foregroundStyle(colorScheme == .dark ? Color.white.opacity(0.8) : Color.gray)
Text("Crie sua conta")
.foregroundStyle(AppColors.primary)
.onTapGesture {
dismiss()
}
}
.buttonStyle(.plain)
.font(AppTypography.body)
.padding(.top, 8)
Spacer()
}
}
}
private func requestOtp() {
let sanitizedEmail = email.trimmingCharacters(in: .whitespacesAndNewlines)
let normalizedPhone = normalizePhoneNumberForAPI(phone)
guard !sanitizedEmail.isEmpty, !normalizedPhone.isEmpty else { return }
isLoading = true
errorMessage = nil
Task {
do {
let service = ApiService()
let response = try await service.requestOtp(email: sanitizedEmail, phoneNumber: normalizedPhone)
await MainActor.run {
if response.error {
isLoading = false
let message = response.message ?? "Nao foi possivel enviar o codigo."
errorMessage = message
SnackbarCenter.shared.show(title: message, style: .error, icon: "xmark.octagon.fill", duration: 4.0)
return
}
isLoading = false
SnackbarCenter.shared.show(title: "Codigo enviado com sucesso.", style: .info, icon: "paperplane.fill", duration: 3.0)
appState.profile.email = sanitizedEmail
appState.profile.phone = phone
navigate(.otp(email: sanitizedEmail, phoneNumber: normalizedPhone))
}
} catch {
await MainActor.run {
isLoading = false
let message = userFacingAuthErrorMessage(error)
errorMessage = message
SnackbarCenter.shared.show(title: message, style: .error, icon: "xmark.octagon.fill", duration: 4.0)
}
}
}
}
}
struct LoginField: View {
let icon: String
let placeholder: String
let keyboardType: UIKeyboardType
@Binding var text: String
var body: some View {
HStack(spacing: 12) {
Image(systemName: icon)
.foregroundStyle(Color.gray)
.frame(width: 28)
TextField(text: $text, prompt: Text(placeholder).foregroundColor(Color.gray)) { }
.appNoAutoCap()
.foregroundColor(.black)
.keyboardType(keyboardType)
}
.padding(.horizontal, 16)
.frame(height: 52)
.background(Color.white)
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: 16, style: .continuous)
.stroke(Color.black.opacity(0.06), lineWidth: 1)
)
}
}

View File

@@ -0,0 +1,142 @@
import SwiftUI
struct LoginView: View {
@Binding var root: RootFlow
@Binding var selectedTab: MainTab
let tokenStore: TokenStore
@Binding var appState: AppState
@Environment(\.colorScheme) var colorScheme
var shouldPrepareEntryAnimation: Bool = false
var authEntryAnimationToken: Int = 0
let navigate: (Route) -> Void
@State var heroVisible = true
@State var textVisible = true
@State var buttonVisible = true
@State var lastAnimatedToken = 0
@ViewBuilder private var logoImage: some View {
SwiftUI.Image("pedifoods")
.resizable()
}
@ViewBuilder private var pinHeroImage: some View {
SwiftUI.Image("pin_image_app")
.resizable()
}
var body: some View {
GeometryReader { geo in
let heroHeight = max(360, geo.size.height * 0.44)
let logoTopInset = max(0, (geo.size.height - 180) / 2)
ZStack(alignment: .top) {
(colorScheme == .dark ? Color.black : AppColors.backgroundLight)
.ignoresSafeArea()
pinHeroImage
.scaledToFill()
.frame(height: heroHeight + 80)
.offset(y: heroVisible ? -60 : -(heroHeight + 220))
.mask(
LinearGradient(
colors: [.black, .black, .black.opacity(0.0)],
startPoint: .top,
endPoint: .bottom
)
)
VStack(spacing: 18) {
Spacer().frame(height: logoTopInset)
logoImage
.scaledToFit()
.frame(height: 180)
Text("Sua vontade, no seu tempo.\nTudo o que você precisa, em um toque.")
.font(AppTypography.heading25)
.foregroundStyle(colorScheme == .dark ? AppColors.textInverse : AppColors.textPrimary)
.multilineTextAlignment(.leading)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 28)
.opacity(textVisible ? 1.0 : 0.0)
.offset(y: textVisible ? 0 : 24)
Spacer().frame(height: 40)
Button {
navigate(.loginEmail)
} label: {
PrimaryButtonLabel(title: "ENTRAR")
}
.padding(.horizontal, 28)
.tint(AppColors.tertiary)
.offset(y: buttonVisible ? 0 : 140)
.opacity(buttonVisible ? 1.0 : 0.0)
.buttonStyle(.plain)
HStack(spacing: 6) {
Text("Não tem conta ainda?")
.foregroundStyle(colorScheme == .dark ? AppColors.textInverse.opacity(0.9) : AppColors.textPrimary)
Button("Criar conta") {
navigate(.registration)
}
.buttonStyle(.plain)
.foregroundStyle(AppColors.primary)
}
.font(AppTypography.body)
.opacity(textVisible ? 1.0 : 0.0)
.offset(y: textVisible ? 0 : 24)
Spacer().frame(height: 12)
}
}
.ignoresSafeArea()
.onAppear {
if shouldPrepareEntryAnimation {
applyHiddenStateWithoutAnimation()
} else {
showFinalStateWithoutAnimation()
}
}
.task(id: authEntryAnimationToken) {
await runEntryAnimationIfNeeded(for: authEntryAnimationToken)
}
}
}
@MainActor
private func applyHiddenStateWithoutAnimation() {
heroVisible = false
textVisible = false
buttonVisible = false
}
@MainActor
private func showFinalStateWithoutAnimation() {
heroVisible = true
textVisible = true
buttonVisible = true
}
@MainActor
private func runEntryAnimationIfNeeded(for token: Int) async {
guard token > 0 else { return }
guard token != lastAnimatedToken else { return }
lastAnimatedToken = token
applyHiddenStateWithoutAnimation()
try? await Task.sleep(nanoseconds: 40_000_000)
withAnimation(.spring(response: 0.64, dampingFraction: 0.9)) {
heroVisible = true
}
try? await Task.sleep(nanoseconds: 160_000_000)
withAnimation(.easeOut(duration: 0.42)) {
textVisible = true
}
try? await Task.sleep(nanoseconds: 150_000_000)
withAnimation(.spring(response: 0.52, dampingFraction: 0.86)) {
buttonVisible = true
}
}
}

View File

@@ -0,0 +1,391 @@
import SwiftUI
import UIKit
struct OtpView: View {
private let resendDelaySeconds = 45
let email: String
let phoneNumber: String
@Binding var root: RootFlow
@Binding var selectedTab: MainTab
let tokenStore: TokenStore
@Binding var appState: AppState
@State var otp = ""
@State var isLoading = false
@State var isResending = false
@State var resendCountdown = 45
@State var canResend = false
@State var errorMessage: String?
@State var countdownTask: Task<Void, Never>?
@FocusState var isOtpFocused: Bool
@Environment(\.dismiss) var dismiss
var body: some View {
ScrollView {
VStack(spacing: 0) {
SwiftUI.Image("pedifoods")
.resizable()
.scaledToFit()
.frame(width: 74, height: 74)
.padding(.top, 140)
Text("Verificação")
.font(AppTypography.heading1)
.foregroundStyle(AppColors.textPrimary)
.padding(.top, 26)
Text("Insira o código de 8 dígitos enviado")
.font(AppTypography.body)
.foregroundStyle(AppColors.textMuted)
.padding(.top, 14)
Text(otpDeliveryMessage)
.font(AppTypography.body)
.foregroundStyle(AppColors.textMuted)
.lineLimit(1)
.minimumScaleFactor(0.85)
.padding(.top, 2)
.padding(.horizontal, 24)
ZStack {
otpGrid
TextField("", text: $otp)
.appOTPKeyboard()
.foregroundStyle(Color.clear)
.tint(Color.clear)
.focused($isOtpFocused)
.frame(maxWidth: CGFloat.greatestFiniteMagnitude, maxHeight: CGFloat.greatestFiniteMagnitude)
.opacity(0.02)
.onChange(of: otp) { _, newValue in
let digits = newValue.filter { $0.isNumber }
let trimmed = String(digits.prefix(8))
if trimmed != newValue {
otp = trimmed
}
if trimmed.count == 8 && !isLoading {
isOtpFocused = false
validateOtp()
}
}
}
.frame(height: 204)
.onTapGesture {
isOtpFocused = true
autoFillOtpFromClipboardIfAvailable()
}
.onLongPressGesture {
pasteOtpFromClipboard()
}
.padding(.horizontal, 24)
.padding(.top, 26)
HStack(spacing: 8) {
Text("Não recebeu o código?")
.font(AppTypography.body)
.foregroundStyle(AppColors.textMuted)
Button(resendButtonTitle) {
resendOtp()
}
.buttonStyle(.plain)
.font(AppTypography.heading3)
.foregroundStyle(AppColors.primary)
.disabled(isResending || !canResend)
}
.padding(.top, 22)
Button("Colar código") {
pasteOtpFromClipboard()
}
.buttonStyle(.plain)
.font(AppTypography.heading3)
.foregroundStyle(AppColors.primary)
.padding(.top, 22)
PrimaryButton(title: "Verificar e Entrar") {
validateOtp()
}
.padding(.horizontal, 24)
.padding(.top, 28)
.disabled(isLoading || otp.count != 8)
.opacity((isLoading || otp.count != 8) ? 0.6 : 1.0)
HStack(spacing: 8) {
Image(systemName: "lock.fill")
.font(.caption)
Text("Conexão segura e criptografada")
.font(AppTypography.body)
}
.foregroundStyle(AppColors.textMuted.opacity(0.8))
.padding(.top, 120)
.padding(.bottom, 18)
}
}
.scrollDismissesKeyboard(.interactively)
.background(AppColors.backgroundLight)
.ignoresSafeArea()
.onAppear {
isOtpFocused = true
startResendCooldown()
}
.onDisappear {
countdownTask?.cancel()
countdownTask = nil
}
}
private var resendButtonTitle: String {
if !canResend {
return String(format: "Reenviar em 00:%02d", resendCountdown)
}
return "Reenviar código"
}
private var otpGrid: some View {
VStack(spacing: 16) {
HStack(spacing: 14) {
otpCell(index: 0)
otpCell(index: 1)
otpCell(index: 2)
otpCell(index: 3)
}
HStack(spacing: 14) {
otpCell(index: 4)
otpCell(index: 5)
otpCell(index: 6)
otpCell(index: 7)
}
}
}
private func otpCell(index: Int) -> some View {
let char = otpCharacter(at: index)
return ZStack {
RoundedRectangle(cornerRadius: 16, style: .continuous)
.fill(Color.white)
.overlay(
RoundedRectangle(cornerRadius: 16, style: .continuous)
.stroke(Color(hex: "#EEF3FA"), lineWidth: 2)
)
if let char {
Text(String(char))
.font(AppTypography.heading2)
.foregroundStyle(AppColors.textPrimary)
} else {
Circle()
.fill(AppColors.textMuted.opacity(0.8))
.frame(width: 10, height: 10)
}
}
.frame(height: 94)
}
private func otpCharacter(at index: Int) -> Character? {
guard index < otp.count else { return nil }
return Array(otp)[index]
}
private func pasteOtpFromClipboard() {
let raw = appReadClipboardText() ?? ""
let digits = raw.filter(\.isNumber)
let trimmed = String(digits.prefix(8))
if trimmed.isEmpty == false {
otp = trimmed
}
}
private func autoFillOtpFromClipboardIfAvailable() {
guard otp.isEmpty else { return }
let raw = appReadClipboardText() ?? ""
let digits = raw.filter(\.isNumber)
guard digits.count >= 8 else { return }
otp = String(digits.prefix(8))
}
private var otpDeliveryMessage: String {
"para o seu telefone \(maskedPhoneForDisplay) e seu email \(maskedEmailForDisplay)"
}
private var maskedPhoneForDisplay: String {
let digits = phoneNumber.filter(\.isNumber)
guard digits.isEmpty == false else { return "XXXX" }
let visibleSuffix = String(digits.suffix(min(4, digits.count)))
return "XXXX\(visibleSuffix)"
}
private var maskedEmailForDisplay: String {
let trimmed = email.trimmingCharacters(in: .whitespacesAndNewlines)
guard trimmed.isEmpty == false else { return "XXXX" }
let parts = trimmed.split(separator: "@", omittingEmptySubsequences: false)
guard parts.count == 2 else { return "XXXX" }
let domain = String(parts[1])
return "XXXX@\(domain)"
}
private func validateOtp() {
let code = otp.filter(\.isNumber)
guard code.count == 8 else { return }
isLoading = true
errorMessage = nil
Task {
do {
let service = ApiService()
let response = try await service.validateOtp(email: email, phoneNumber: phoneNumber, otp: code)
await MainActor.run {
if response.error {
isLoading = false
let message = response.message ?? "Codigo invalido."
errorMessage = message
SnackbarCenter.shared.show(title: message, style: .error, icon: "xmark.octagon.fill", duration: 4.0)
return
}
isLoading = false
SnackbarCenter.shared.show(title: "Login realizado com sucesso.", style: .success, icon: "checkmark.seal.fill", duration: 2.5)
appState.session.isAuthenticated = true
appState.session.jwt = response.result?.token
let hasServerAddress = hydrateUserState(from: response.result?.customer)
routeAfterLogin(hasServerAddress: hasServerAddress)
Task { await PushNotificationCoordinator.shared.syncCustomerAttributes() }
}
} catch {
await MainActor.run {
isLoading = false
let message = userFacingAuthErrorMessage(error)
errorMessage = message
SnackbarCenter.shared.show(title: message, style: .error, icon: "xmark.octagon.fill", duration: 4.0)
}
}
}
}
private func routeAfterLogin(hasServerAddress: Bool) {
if hasServerAddress || hasConfiguredAddress() {
selectedTab = .home
root = .main
return
}
appState.address.onboardingMessage = "Vamos criar um endereço para poder visualizar os estabelecimentos mais próximos de você?"
selectedTab = .profile
root = .main
}
private func hydrateUserState(from customer: CustomerProfile?) -> Bool {
if let customer {
appState.profile.id = customer.id
appState.profile.name = customer.name
appState.profile.email = customer.email
appState.profile.phone = customer.phoneNumber ?? ""
appState.profile.profilePicture = customer.profilePicture ?? ""
appState.profile.notificationsEnabled = customer.notificationsEnabled ?? false
appState.profile.faceIdEnabled = customer.faceIdEnabled ?? false
appState.favorites.storeIds = Set(customer.favorites ?? [])
SessionStateStore.setActiveUserKey(
SessionStateStore.makeUserKey(profileId: customer.id, email: customer.email)
)
if let cachedCart = SessionStateStore.loadCart() {
appState.cart = cachedCart
} else {
appState.cart = CartState()
}
let addresses = customer.addressBook ?? []
if let preferred = addresses.first {
appState.address.selectedId = preferred.id
let label = (preferred.label ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
appState.address.display = label.isEmpty ? "Defina seu endereco" : label
if let lat = preferred.latLong?.first, let lng = preferred.latLong?.dropFirst().first {
appState.address.latitude = lat
appState.address.longitude = lng
} else {
appState.address.latitude = nil
appState.address.longitude = nil
}
SessionStateStore.saveAddress(appState.address)
} else {
appState.address = AddressState()
SessionStateStore.clearAddress()
}
return addresses.isEmpty == false
}
appState.profile.email = email
appState.favorites = FavoritesState()
SessionStateStore.setActiveUserKey(
SessionStateStore.makeUserKey(profileId: nil, email: email)
)
if let cachedCart = SessionStateStore.loadCart() {
appState.cart = cachedCart
} else {
appState.cart = CartState()
}
appState.address = AddressState()
return false
}
private func hasConfiguredAddress() -> Bool {
if appState.address.selectedId != nil {
return true
}
let normalized = appState.address.display
.trimmingCharacters(in: .whitespacesAndNewlines)
.lowercased()
return normalized.isEmpty == false && normalized != "defina seu endereco"
}
private func resendOtp() {
guard canResend, !isResending else { return }
isResending = true
startResendCooldown()
errorMessage = nil
Task {
do {
let service = ApiService()
let response = try await service.requestOtp(email: email, phoneNumber: phoneNumber)
await MainActor.run {
isResending = false
if response.error {
canResend = true
resendCountdown = 0
let message = response.message ?? "Nao foi possivel reenviar o código."
errorMessage = message
SnackbarCenter.shared.show(title: message, style: .warning, icon: "exclamationmark.triangle.fill", duration: 4.0)
} else {
SnackbarCenter.shared.show(title: "Codigo reenviado.", style: .info, icon: "paperplane.fill", duration: 2.5)
}
}
} catch {
await MainActor.run {
isResending = false
canResend = true
resendCountdown = 0
let message = userFacingAuthErrorMessage(error)
errorMessage = message
SnackbarCenter.shared.show(title: message, style: .error, icon: "xmark.octagon.fill", duration: 4.0)
}
}
}
}
private func startResendCooldown() {
countdownTask?.cancel()
canResend = false
resendCountdown = resendDelaySeconds
countdownTask = Task {
var remaining = resendDelaySeconds
while !Task.isCancelled && remaining > 0 {
try? await Task.sleep(nanoseconds: 1_000_000_000)
remaining -= 1
await MainActor.run {
resendCountdown = max(remaining, 0)
canResend = remaining == 0
}
}
}
}
}

View File

@@ -0,0 +1,168 @@
import SwiftUI
struct RegistrationView: View {
@Binding var root: RootFlow
@Binding var selectedTab: MainTab
let tokenStore: TokenStore
@Binding var appState: AppState
@State var name = ""
@State var email = ""
@State var phone = ""
@State var acceptedTerms = false
@State var isLoading = false
@State var errorMessage: String?
@Environment(\.dismiss) var dismiss
@Environment(\.colorScheme) var colorScheme
let navigate: (Route) -> Void
private var isFormValid: Bool { !name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && !email.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && !phone.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && acceptedTerms }
@ViewBuilder private var logoImage: some View {
SwiftUI.Image("pedifoods")
.resizable()
}
var body: some View {
ZStack {
(colorScheme == .dark ? Color.black : AppColors.backgroundLight).ignoresSafeArea()
ScrollView {
VStack(spacing: 0) {
logoImage
.scaledToFit()
.frame(width: 120, height: 120)
Text("Crie sua conta")
.font(AppTypography.heading1)
.foregroundStyle(colorScheme == .dark ? Color.white : AppColors.textPrimary)
Text("Preencha os dados abaixo para começar.")
.font(AppTypography.body)
.foregroundStyle(colorScheme == .dark ? Color.white : AppColors.textPrimary)
.padding(.top, 8)
.padding(.bottom, 20)
VStack(spacing: 16) {
VStack(alignment: .leading, spacing: 16) {
LoginField(icon: "person", placeholder: "Ex: Maria Silva", keyboardType: .default, text: $name)
LoginField(icon: "envelope", placeholder: "seu@email.com", keyboardType: .emailAddress, text: $email)
LoginField(icon: "phone", placeholder: "(00) 00000-0000", keyboardType: .namePhonePad, text: $phone)
.onChange(of: phone) { _, newValue in
let masked = formatPhoneBR(newValue)
if masked != newValue {
phone = masked
}
}
}
}
.padding(.horizontal, 24)
HStack(alignment: .top, spacing: 12) {
Toggle("", isOn: $acceptedTerms)
.labelsHidden()
.tint(AppColors.primary)
Group {
Text("Ao se cadastrar, você concorda com nossos [Termos de Uso](app://terms) e [Política de Privacidade](app://policy)")
.foregroundStyle(colorScheme == .dark ? Color.white : AppColors.textPrimary)
.tint(AppColors.primary)
.environment(\.openURL, OpenURLAction { url in
guard url.scheme == "app" else { return .handled }
switch url.host {
case "terms":
navigate(.terms)
return .handled
case "policy":
navigate(.policy)
return .handled
default:
return .handled
}
})
}
.multilineTextAlignment(.leading)
}
.padding(.horizontal, 24)
.padding(.top, 16)
.padding(.bottom, 16)
PrimaryButton(title: "Cadastrar e Receber Código", image: Image(systemName: "arrow.right")) {
registerAndRequestOtp()
}
.padding(.horizontal, 24)
.padding(.top, 6)
.disabled(!isFormValid || isLoading)
.opacity((!isFormValid || isLoading) ? 0.5 : 1.0)
.tint(AppColors.tertiary)
HStack(spacing: 6) {
Text("Já tem uma conta?")
.foregroundStyle(colorScheme == .dark ? Color.white.opacity(0.8) : AppColors.textPrimary)
NavigationLink("Entrar") {
LoginEmailView(root: $root, selectedTab: $selectedTab, tokenStore: tokenStore, appState: $appState, navigate: navigate)
}
.foregroundStyle(AppColors.primary)
}
.font(AppTypography.body)
.padding(.top, 16)
Spacer().frame(height: 12)
}
}
.padding(.top, -40)
}
}
private func registerAndRequestOtp() {
let sanitizedName = name.trimmingCharacters(in: .whitespacesAndNewlines)
let sanitizedEmail = email.trimmingCharacters(in: .whitespacesAndNewlines)
let normalizedPhone = normalizePhoneNumberForAPI(phone)
guard !sanitizedName.isEmpty, !sanitizedEmail.isEmpty, !normalizedPhone.isEmpty else { return }
isLoading = true
errorMessage = nil
Task {
do {
let service = ApiService()
let registration = try await service.registerCustomer(
name: sanitizedName,
email: sanitizedEmail,
phoneNumber: normalizedPhone
)
if registration.error {
await MainActor.run {
isLoading = false
let message = registration.message ?? "Nao foi possivel concluir o cadastro."
errorMessage = message
SnackbarCenter.shared.show(title: message, style: .error, icon: "xmark.octagon.fill", duration: 4.0)
}
return
}
let otp = try await service.requestOtp(email: sanitizedEmail, phoneNumber: normalizedPhone)
await MainActor.run {
isLoading = false
if otp.error {
let message = otp.message ?? "Cadastro concluido, mas nao foi possivel enviar o codigo."
errorMessage = message
SnackbarCenter.shared.show(title: message, style: .warning, icon: "exclamationmark.triangle.fill", duration: 4.0)
return
}
SnackbarCenter.shared.show(title: "Cadastro concluido. Codigo enviado.", style: .success, icon: "checkmark.seal.fill", duration: 3.0)
appState.profile.email = sanitizedEmail
appState.profile.phone = phone
navigate(.otp(email: sanitizedEmail, phoneNumber: normalizedPhone))
}
} catch {
await MainActor.run {
isLoading = false
let message = userFacingAuthErrorMessage(error)
errorMessage = message
SnackbarCenter.shared.show(title: message, style: .error, icon: "xmark.octagon.fill", duration: 4.0)
}
}
}
}
}

View File

@@ -0,0 +1,42 @@
import SwiftUI
struct LaunchSplashView: View {
var shouldPulse: Bool = true
@Environment(\.colorScheme) var colorScheme
@State var isAnimating = false
var body: some View {
GeometryReader { geo in
let logoTopInset = max(0, (geo.size.height - 180) / 2 - 8)
ZStack(alignment: .top) {
(colorScheme == .dark ? Color.black : AppColors.backgroundLight)
.ignoresSafeArea()
VStack(spacing: 0) {
Spacer().frame(height: logoTopInset)
splashLogo
.scaledToFit()
.frame(width: 180, height: 180)
.scaleEffect(isAnimating ? 1.03 : 0.97)
.opacity(isAnimating ? 1.0 : 0.9)
.animation(.easeInOut(duration: 0.9).repeatForever(autoreverses: true), value: isAnimating)
Spacer()
}
}
}
.onAppear {
isAnimating = shouldPulse
}
.onChange(of: shouldPulse) { _, newValue in
isAnimating = newValue
}
}
@ViewBuilder var splashLogo: some View {
SwiftUI.Image("pedifoods")
.resizable()
}
}

View File

@@ -0,0 +1,254 @@
import SwiftUI
struct AddAddressFormView: View {
@Environment(\.dismiss) var dismiss
@State var label = ""
@State var zipCode = ""
@State var address = ""
@State var number = ""
@State var complement = ""
@State var neighborhood = ""
@State var city = ""
@State var state = ""
@State var isLoading = false
@State var isLookingUpZipCode = false
@State var zipLookupMessage: String? = nil
@State var lastLookedUpZipCode = ""
@State var lookedUpLatitude: Double? = nil
@State var lookedUpLongitude: Double? = nil
let existingAddress: CustomerAddress?
let onSave: ([CustomerAddress], Bool) -> Void
private var isFormValid: Bool {
!label.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty &&
normalizeZipCodeForAPI(zipCode).count == 8 &&
!address.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty &&
!number.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty &&
!neighborhood.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty &&
!city.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty &&
!state.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
}
var body: some View {
ZStack {
AppColors.backgroundLight.ignoresSafeArea()
ScrollView(showsIndicators: false) {
VStack(spacing: 0) {
screenHeader(
title: existingAddress == nil ? "Novo endereço" : "Editar endereço",
onBack: { dismiss() }
)
.padding(.horizontal, 20)
.padding(.bottom, 20)
VStack(alignment: .leading, spacing: 16) {
LoginField(icon: "tag", placeholder: "Ex: Casa, Trabalho", keyboardType: .default, text: $label)
LoginField(icon: "mail", placeholder: "CEP", keyboardType: .numberPad, text: $zipCode)
.onChange(of: zipCode) { _, newValue in
let masked = formatZipCodeBR(newValue)
if masked != newValue {
zipCode = masked
}
let normalized = normalizeZipCodeForAPI(masked)
if normalized.count == 8, normalized != lastLookedUpZipCode, !isLookingUpZipCode {
Task {
await lookupAddressByZipCode(normalized)
}
}
}
if isLookingUpZipCode {
HStack(spacing: 8) {
ProgressView()
Text("Buscando endereço pelo CEP...")
.font(.caption)
.foregroundStyle(AppColors.textMuted)
}
.padding(.horizontal, 6)
} else if let zipLookupMessage {
Text(zipLookupMessage)
.font(.caption)
.foregroundStyle(AppColors.textMuted)
.padding(.horizontal, 6)
}
LoginField(icon: "mappin.and.ellipse", placeholder: "Rua / Avenida", keyboardType: .default, text: $address)
LoginField(icon: "number", placeholder: "Número", keyboardType: .default, text: $number)
LoginField(icon: "plus.app", placeholder: "Complemento (opcional)", keyboardType: .default, text: $complement)
LoginField(icon: "square.grid.2x2", placeholder: "Bairro", keyboardType: .default, text: $neighborhood)
LoginField(icon: "building.2", placeholder: "Cidade", keyboardType: .default, text: $city)
LoginField(icon: "map", placeholder: "Estado (UF)", keyboardType: .default, text: $state)
.onChange(of: state) { _, newValue in
let normalized = String(newValue.uppercased().prefix(2))
if normalized != newValue {
state = normalized
}
}
}
.padding(.horizontal, 24)
.padding(.bottom, 20)
PrimaryButton(title: existingAddress == nil ? "Salvar endereço" : "Atualizar endereço", image: Image(systemName: "checkmark")) {
saveAddress()
}
.padding(.horizontal, 24)
.disabled(!isFormValid || isLoading)
.opacity((!isFormValid || isLoading) ? 0.5 : 1.0)
.tint(AppColors.tertiary)
SecondaryButton(title: "Cancelar") {
dismiss()
}
.padding(.horizontal, 24)
.padding(.top, 12)
Spacer().frame(height: 120)
}
}
.padding(.top, 18)
}
.navigationBarBackButtonHidden(true)
.appHiddenNavigationBar()
.onAppear {
populateFromExistingAddressIfNeeded()
}
}
private func screenHeader(title: String, onBack: @escaping () -> Void) -> some View {
ZStack {
Text(title)
.font(AppTypography.heading2)
.foregroundStyle(AppColors.textPrimary)
HStack {
Button(action: onBack) {
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()
}
}
}
private func saveAddress() {
guard !isLoading else { return }
let latLong: [Double]? = {
if let lat = lookedUpLatitude, let lng = lookedUpLongitude {
return [lat, lng]
}
return nil
}()
let newAddress = CustomerAddress(
id: existingAddress?.id ?? UUID().uuidString,
label: clean(label),
address: clean(address),
number: clean(number),
complement: optional(clean(complement)),
neighborhood: clean(neighborhood),
city: clean(city),
state: clean(state),
zipCode: optional(normalizeZipCodeForAPI(zipCode)),
latLong: latLong,
isDefault: existingAddress?.isDefault
)
isLoading = true
zipLookupMessage = nil
Task {
do {
let response = try await ApiService().saveCustomerAddress(newAddress, replacingAddressId: existingAddress?.id)
await MainActor.run {
isLoading = false
if response.error {
zipLookupMessage = response.message ?? "Não foi possível salvar o endereço."
SnackbarCenter.shared.show(title: zipLookupMessage ?? "Não foi possível salvar o endereço.", style: .error, icon: "xmark.octagon.fill", duration: 4.0)
return
}
let updatedAddresses = response.result?.addressBook ?? [newAddress]
onSave(updatedAddresses, existingAddress != nil)
dismiss()
}
} catch {
await MainActor.run {
isLoading = false
let message = error.localizedDescription
zipLookupMessage = message
SnackbarCenter.shared.show(title: message, style: .error, icon: "xmark.octagon.fill", duration: 4.0)
}
}
}
}
private func populateFromExistingAddressIfNeeded() {
guard let existingAddress else { return }
label = existingAddress.label ?? ""
zipCode = formatZipCodeBR(existingAddress.zipCode ?? "")
lastLookedUpZipCode = normalizeZipCodeForAPI(zipCode)
address = existingAddress.address ?? ""
number = existingAddress.number ?? ""
complement = existingAddress.complement ?? ""
neighborhood = existingAddress.neighborhood ?? ""
city = existingAddress.city ?? ""
state = String((existingAddress.state ?? "").uppercased().prefix(2))
lookedUpLatitude = existingAddress.latLong?.first
lookedUpLongitude = existingAddress.latLong?.dropFirst().first
}
private func clean(_ value: String) -> String {
value.trimmingCharacters(in: .whitespacesAndNewlines)
}
private func optional(_ value: String) -> String? {
value.isEmpty ? nil : value
}
@MainActor
private func lookupAddressByZipCode(_ zip: String) async {
isLookingUpZipCode = true
zipLookupMessage = nil
defer { isLookingUpZipCode = false }
do {
let response = try await ApiService().lookupZipCode(zip)
lastLookedUpZipCode = zip
guard response.error == false, let result = response.result else {
zipLookupMessage = response.message ?? "Não foi possível consultar este CEP."
return
}
fillAddressFields(with: result)
zipLookupMessage = "Endereço preenchido automaticamente."
} catch {
zipLookupMessage = "Não foi possível consultar o CEP agora."
}
}
private func fillAddressFields(with result: CepLookupResult) {
if address.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
address = result.street?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
}
if neighborhood.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
neighborhood = result.neighborhood?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
}
if city.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
city = result.city?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
}
if state.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
state = String((result.state ?? "").uppercased().prefix(2))
}
if complement.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
complement = result.complement?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
}
lookedUpLatitude = result.latitude
lookedUpLongitude = result.longitude
}
}

View File

@@ -0,0 +1,461 @@
import SwiftUI
struct AddCardFormView: View {
let appState: AppState
let isFirstCard: Bool
let onCardAdded: (SavedCard) -> Void
@Environment(\.dismiss) var dismiss
@State private var cardNumber = ""
@State private var holderName = ""
@State private var expiry = ""
@State private var cvv = ""
@State private var cpf = ""
@State private var nickname = ""
@State private var isDefault = false
@State private var isSaving = false
@State private var addresses: [CustomerAddress] = []
@State private var selectedAddress: CustomerAddress? = nil
@State private var isLoadingAddresses = false
@State private var showAddressPicker = false
private var detectedBrandLogo: String? {
let clean = cardNumber.filter(\.isNumber)
guard clean.isEmpty == false else { return nil }
if clean.hasPrefix("506766") || clean.hasPrefix("603389") { return "sodexo_logo" }
if clean.hasPrefix("5067") || clean.hasPrefix("5078") || clean.hasPrefix("6278") || clean.hasPrefix("6367") { return "alelocard_logo" }
if clean.hasPrefix("3841") || clean.hasPrefix("606282") || clean.hasPrefix("637095") || clean.hasPrefix("637568") { return "hipercard_logo" }
if clean.hasPrefix("34") || clean.hasPrefix("37") { return "amexcard_logo" }
if clean.hasPrefix("4") { return "visacard_logo" }
let prefix2 = Int(clean.prefix(2)) ?? 0
if (51...59).contains(prefix2) { return "mastercard_logo" }
if let p4 = Int(clean.prefix(4)), (2221...2720).contains(p4) { return "mastercard_logo" }
return nil
}
private var selectedAddressZip: String {
(selectedAddress?.zipCode ?? "").filter(\.isNumber)
}
private var canSave: Bool {
let digits = cardNumber.filter(\.isNumber)
let cpfDigits = cpf.filter(\.isNumber)
let parts = expiry.split(separator: "/")
return digits.count >= 13
&& holderName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false
&& parts.count == 2
&& cvv.count >= 3
&& cpfDigits.count == 11
&& selectedAddress != nil
&& selectedAddressZip.count >= 7
}
var body: some View {
ScrollView(showsIndicators: false) {
VStack(spacing: 20) {
screenHeader
formSection("Dados do Cartão") {
cardNumberField
labeledField("Nome no cartão", placeholder: "Como impresso no cartão", text: $holderName, autocap: true)
HStack(spacing: 12) {
labeledField("Validade", placeholder: "MM/AA", text: $expiry, keyboard: .numberPad)
.onChange(of: expiry) { _, v in expiry = formatExpiry(v) }
labeledField("CVV", placeholder: "•••", text: $cvv, keyboard: .numberPad)
.onChange(of: cvv) { _, v in cvv = String(v.filter(\.isNumber).prefix(4)) }
}
}
formSection("Identificação do Titular") {
labeledField("CPF", placeholder: "000.000.000-00", text: $cpf, keyboard: .numberPad)
.onChange(of: cpf) { _, v in cpf = formatCPF(v.filter(\.isNumber)) }
addressPickerRow
}
formSection("Opções") {
labeledField("Apelido (opcional)", placeholder: "Ex: Cartão do Nubank", text: $nickname)
if isFirstCard == false {
Toggle(isOn: $isDefault) {
Text("Definir como principal")
.font(AppTypography.heading3)
.foregroundStyle(AppColors.textPrimary)
}
.tint(AppColors.primary)
.padding(14)
.background(AppColors.surface)
.clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous))
}
}
Button(action: { Task { await saveCard() } }) {
Group {
if isSaving {
ProgressView().tint(Color(hex: "#0E1A06"))
} else {
Text("Salvar Cartão").font(AppTypography.heading2)
}
}
.foregroundStyle(Color(hex: "#0E1A06"))
.frame(maxWidth: .infinity, minHeight: 56)
.background(canSave && !isSaving ? Color(hex: "#C8F06E") : Color(hex: "#C8F06E").opacity(0.45))
.clipShape(Capsule())
}
.buttonStyle(.plain)
.disabled(!canSave || isSaving)
.padding(.top, 4)
}
.padding(.horizontal, 20)
.padding(.top, 18)
.padding(.bottom, 40)
}
.background(AppColors.backgroundLight)
.navigationBarBackButtonHidden(true)
.appHiddenNavigationBar()
.sheet(isPresented: $showAddressPicker) {
addressPickerSheet
}
.task { await loadData() }
}
// MARK: - Address picker row
private var addressPickerRow: some View {
VStack(alignment: .leading, spacing: 6) {
Text("Endereço de cobrança")
.font(AppTypography.caption)
.foregroundStyle(AppColors.textMuted)
Button {
if addresses.isEmpty == false { showAddressPicker = true }
} label: {
HStack(spacing: 10) {
Image(systemName: "mappin.circle.fill")
.font(.system(size: 18))
.foregroundStyle(selectedAddress != nil ? AppColors.primary : AppColors.textMuted)
VStack(alignment: .leading, spacing: 2) {
if isLoadingAddresses {
Text("Carregando endereços...")
.font(AppTypography.body)
.foregroundStyle(AppColors.textMuted)
} else if let addr = selectedAddress {
Text(addressDisplayTitle(addr))
.font(AppTypography.heading3)
.foregroundStyle(AppColors.textPrimary)
.lineLimit(1)
if let sub = addressDisplaySubtitle(addr) {
Text(sub)
.font(AppTypography.caption)
.foregroundStyle(AppColors.textMuted)
.lineLimit(1)
}
} else if addresses.isEmpty {
Text("Nenhum endereço cadastrado")
.font(AppTypography.body)
.foregroundStyle(AppColors.textMuted)
} else {
Text("Selecionar endereço")
.font(AppTypography.body)
.foregroundStyle(AppColors.textMuted)
}
}
Spacer()
if addresses.isEmpty == false {
Image(systemName: "chevron.right")
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(AppColors.textMuted)
}
}
.padding(14)
.background(AppColors.surface)
.clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous))
}
.buttonStyle(.plain)
.disabled(isLoadingAddresses || addresses.isEmpty)
}
}
// MARK: - Address picker sheet
private var addressPickerSheet: some View {
NavigationStack {
ScrollView(showsIndicators: false) {
VStack(spacing: 10) {
ForEach(Array(addresses.enumerated()), id: \.offset) { _, addr in
Button {
selectedAddress = addr
showAddressPicker = false
} label: {
HStack(spacing: 12) {
Image(systemName: "mappin.circle.fill")
.font(.system(size: 22))
.foregroundStyle(isSelected(addr) ? AppColors.primary : AppColors.textMuted)
VStack(alignment: .leading, spacing: 3) {
Text(addressDisplayTitle(addr))
.font(AppTypography.heading3)
.foregroundStyle(AppColors.textPrimary)
.lineLimit(1)
if let sub = addressDisplaySubtitle(addr) {
Text(sub)
.font(AppTypography.caption)
.foregroundStyle(AppColors.textMuted)
.lineLimit(1)
}
}
Spacer()
if isSelected(addr) {
Image(systemName: "checkmark.circle.fill")
.foregroundStyle(AppColors.primary)
}
}
.padding(14)
.background(AppColors.surface)
.clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous))
}
.buttonStyle(.plain)
}
}
.padding(.horizontal, 20)
.padding(.top, 14)
.padding(.bottom, 30)
}
.background(AppColors.backgroundLight)
.navigationTitle("Endereço de cobrança")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Fechar") { showAddressPicker = false }
.foregroundStyle(AppColors.textPrimary)
}
}
}
}
// MARK: - Sub-views
private var screenHeader: some View {
ZStack {
Text("Novo Cartão")
.font(AppTypography.heading2)
.foregroundStyle(AppColors.textPrimary)
HStack {
Button(action: { dismiss() }) {
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()
}
}
}
private var cardNumberField: some View {
VStack(alignment: .leading, spacing: 6) {
Text("Número do cartão")
.font(AppTypography.caption)
.foregroundStyle(AppColors.textMuted)
HStack(spacing: 8) {
TextField("0000 0000 0000 0000", text: $cardNumber)
.font(AppTypography.heading3)
.foregroundStyle(AppColors.textPrimary)
.keyboardType(.numberPad)
.onChange(of: cardNumber) { _, v in cardNumber = formatCardNumber(v.filter(\.isNumber)) }
let digits = cardNumber.filter(\.isNumber)
if let logo = detectedBrandLogo {
Image(logo)
.resizable()
.scaledToFit()
.frame(width: 40, height: 26)
} else if digits.count >= 4 {
Image(systemName: "creditcard")
.font(.system(size: 22))
.foregroundStyle(AppColors.textMuted)
.frame(width: 40, height: 26)
}
}
.padding(14)
.background(AppColors.surface)
.clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous))
}
}
private func labeledField(
_ label: String,
placeholder: String,
text: Binding<String>,
keyboard: UIKeyboardType = .default,
autocap: Bool = false
) -> some View {
VStack(alignment: .leading, spacing: 6) {
Text(label)
.font(AppTypography.caption)
.foregroundStyle(AppColors.textMuted)
TextField(placeholder, text: text)
.font(AppTypography.heading3)
.foregroundStyle(AppColors.textPrimary)
.keyboardType(keyboard)
.autocorrectionDisabled()
.textInputAutocapitalization(autocap ? .characters : .never)
.padding(14)
.background(AppColors.surface)
.clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous))
}
}
private func formSection(_ title: String, @ViewBuilder content: () -> some View) -> some View {
VStack(alignment: .leading, spacing: 10) {
Text(title)
.font(AppTypography.caption)
.foregroundStyle(AppColors.textMuted)
.padding(.leading, 2)
content()
}
}
// MARK: - Helpers
private func isSelected(_ addr: CustomerAddress) -> Bool {
guard let sel = selectedAddress else { return false }
if let id = addr.id, let selId = sel.id { return id == selId }
return addr.address == sel.address && addr.number == sel.number
}
private func addressDisplayTitle(_ addr: CustomerAddress) -> String {
let label = addr.label?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if label.isEmpty == false { return label }
let street = addr.address?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let number = addr.number?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let base = [street, number].filter { $0.isEmpty == false }.joined(separator: ", ")
return base.isEmpty ? "Endereço" : base
}
private func addressDisplaySubtitle(_ addr: CustomerAddress) -> String? {
let parts = [
addr.neighborhood?.trimmingCharacters(in: .whitespacesAndNewlines),
addr.city?.trimmingCharacters(in: .whitespacesAndNewlines),
addr.state?.trimmingCharacters(in: .whitespacesAndNewlines)
].compactMap { v -> String? in
guard let v, v.isEmpty == false else { return nil }
return v
}
return parts.isEmpty ? nil : parts.joined(separator: ", ")
}
// MARK: - Load & Save
@MainActor
private func loadData() async {
holderName = appState.profile.name
cpf = formatCPF(appState.profile.cpf.filter(\.isNumber))
isDefault = isFirstCard
isLoadingAddresses = true
defer { isLoadingAddresses = false }
if let result = try? await ApiService().profile(forceRefresh: false).result {
let book = result.addressBook ?? []
addresses = book
selectedAddress = book.first
}
}
@MainActor
private func saveCard() async {
guard canSave, let addr = selectedAddress else { return }
isSaving = true
defer { isSaving = false }
let parts = expiry.split(separator: "/")
let month = String(parts[0])
let year: String = {
let y = String(parts[1])
return y.count == 2 ? "20\(y)" : y
}()
let cleanNumber = cardNumber.filter(\.isNumber)
let cleanCpf = cpf.filter(\.isNumber)
let zip = selectedAddressZip
let addrNumber = addr.number?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "0"
let creditCard = SaveCardCreditCardPayload(
holderName: holderName.trimmingCharacters(in: .whitespacesAndNewlines).uppercased(),
number: cleanNumber,
expiryMonth: month,
expiryYear: year,
ccv: cvv
)
let holderInfo = SaveCardHolderInfoPayload(
name: holderName.trimmingCharacters(in: .whitespacesAndNewlines),
email: appState.profile.email,
cpfCnpj: cleanCpf,
postalCode: zip,
addressNumber: addrNumber.isEmpty ? "0" : addrNumber,
phone: appState.profile.phone
)
let payload = SaveCardPayload(
creditCard: creditCard,
creditCardHolderInfo: holderInfo,
nickname: nickname.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? nil : nickname,
isDefault: isDefault || isFirstCard
)
do {
let response = try await ApiService().saveCard(payload: payload)
if response.error == false, let result = response.result {
let newCard = SavedCard(
id: result.id,
nickname: payload.nickname,
holderName: result.holderName,
last4: result.last4,
brand: result.brand,
expiryMonth: result.expiryMonth,
expiryYear: result.expiryYear,
isDefault: result.isDefault
)
onCardAdded(newCard)
dismiss()
SnackbarCenter.shared.show(title: "Cartão salvo com sucesso.", style: .success, icon: "creditcard.fill", duration: 2.5)
} else {
SnackbarCenter.shared.show(title: response.message ?? "Não foi possível salvar o cartão.", style: .error, icon: "xmark.octagon.fill", duration: 3.5)
}
} catch {
SnackbarCenter.shared.show(title: "Erro ao salvar cartão. Verifique os dados e tente novamente.", style: .error, icon: "xmark.octagon.fill", duration: 3.5)
}
}
// MARK: - Formatters
private func formatCardNumber(_ digits: String) -> String {
let d = String(digits.prefix(16))
var result = ""
for (i, c) in d.enumerated() {
if i > 0 && i % 4 == 0 { result += " " }
result.append(c)
}
return result
}
private func formatExpiry(_ value: String) -> String {
let digits = String(value.filter(\.isNumber).prefix(4))
if digits.count > 2 { return "\(digits.prefix(2))/\(digits.dropFirst(2))" }
return digits
}
private func formatCPF(_ digits: String) -> String {
let d = String(digits.prefix(11))
if d.count <= 3 { return d }
if d.count <= 6 { return "\(d.prefix(3)).\(d.dropFirst(3))" }
if d.count <= 9 { return "\(d.prefix(3)).\(d.dropFirst(3).prefix(3)).\(d.dropFirst(6))" }
return "\(d.prefix(3)).\(d.dropFirst(3).prefix(3)).\(d.dropFirst(6).prefix(3))-\(d.dropFirst(9))"
}
}

View File

@@ -0,0 +1,197 @@
import SwiftUI
import UIKit
func formatZipCodeBR(_ input: String) -> String {
let digits = input.filter(\.isNumber)
let limited = String(digits.prefix(8))
if limited.count <= 5 {
return limited
}
let prefix = String(limited.prefix(5))
let suffix = String(limited.dropFirst(5))
return "\(prefix)-\(suffix)"
}
func normalizeZipCodeForAPI(_ input: String) -> String {
String(input.filter(\.isNumber).prefix(8))
}
func triggerLightHaptic() {
UIImpactFeedbackGenerator(style: .light).impactOccurred()
}
func triggerSelectionHaptic() {
UISelectionFeedbackGenerator().selectionChanged()
}
struct SwipeToDeleteAddressRow<Content: View>: View {
let rowId: String
@Binding var openRowId: String?
let isDeleting: Bool
let onDelete: () -> Void
@ViewBuilder var content: () -> Content
@State var contentOffset: CGFloat = 0
private let deleteWidth: CGFloat = 92
private let openThreshold: CGFloat = 32
private var showsDeleteAction: Bool { contentOffset < -2 || openRowId == rowId }
var body: some View {
ZStack(alignment: .trailing) {
HStack(spacing: 0) {
Spacer(minLength: 0)
Button(action: {
triggerLightHaptic()
onDelete()
}) {
VStack(spacing: 8) {
Image(systemName: "trash.fill")
.font(.system(size: 20, weight: .semibold))
Text(isDeleting ? "..." : "Excluir")
.font(AppTypography.overline)
}
.foregroundStyle(AppColors.textInverse)
.frame(width: deleteWidth)
.frame(maxHeight: .infinity)
.background(Color.red)
}
.buttonStyle(.plain)
.disabled(isDeleting)
.opacity(showsDeleteAction ? 1 : 0)
.clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusXL, style: .continuous))
}
content()
.offset(x: contentOffset)
.gesture(
DragGesture(minimumDistance: 8)
.onChanged { value in
guard isDeleting == false else { return }
if value.translation.width < 0 {
contentOffset = max(-deleteWidth, value.translation.width)
} else if openRowId == rowId {
contentOffset = min(0, -deleteWidth + value.translation.width)
}
}
.onEnded { _ in
guard isDeleting == false else { return }
if contentOffset <= -openThreshold {
let wasClosed = openRowId != rowId
contentOffset = -deleteWidth
openRowId = rowId
if wasClosed {
triggerSelectionHaptic()
}
} else {
contentOffset = 0
if openRowId == rowId {
openRowId = nil
}
}
}
)
.animation(.easeOut(duration: 0.18), value: contentOffset)
}
.clipped()
.animation(.easeOut(duration: 0.18), value: showsDeleteAction)
.onChange(of: openRowId) { _, newValue in
if newValue != rowId {
contentOffset = 0
}
}
.onChange(of: isDeleting) { _, newValue in
if newValue {
contentOffset = 0
}
}
}
}
struct AddressCard: View {
let item: AddressListItem
var onEdit: (() -> Void)? = nil
var onDelete: (() -> Void)? = nil
var onSetDefault: (() -> Void)? = nil
private var hasActions: Bool { onEdit != nil || onSetDefault != nil }
var body: some View {
HStack(spacing: 14) {
icon
VStack(alignment: .leading, spacing: 8) {
HStack(spacing: 10) {
Text(item.title)
.font(AppTypography.heading2)
.foregroundStyle(AppColors.textPrimary)
.lineLimit(1)
.minimumScaleFactor(0.9)
if item.isPrimary {
Text("PRINCIPAL")
.font(AppTypography.overline)
.foregroundStyle(AppColors.textPrimary)
.lineLimit(1)
.padding(EdgeInsets(top: 5, leading: 9, bottom: 5, trailing: 9))
.background(AppColors.tertiary)
.clipShape(Capsule())
}
}
Text(item.detail)
.font(AppTypography.body)
.foregroundStyle(AppColors.textMuted)
.lineLimit(2)
}
Spacer(minLength: 8)
if hasActions {
Rectangle()
.fill(AppColors.backgroundLight)
.frame(width: 1, height: 96)
}
VStack(spacing: 20) {
if let onEdit {
Button(action: onEdit) {
Image(systemName: "pencil")
.font(.system(size: 20))
.foregroundStyle(AppColors.textMuted)
}
}
if let onSetDefault, item.isPrimary == false {
Button(action: onSetDefault) {
Image(systemName: "star")
.font(.system(size: 20))
.foregroundStyle(AppColors.textMuted)
}
}
}
.frame(width: hasActions ? 40 : 0)
}
.padding(.horizontal, 16)
.padding(.vertical, 20)
.background(AppColors.surface)
.clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusXL, style: .continuous))
}
var icon: some View {
Image(systemName: item.icon)
.font(.system(size: 28))
.foregroundStyle(item.isPrimary ? AppColors.primary : AppColors.textPrimary)
.frame(width: 84, height: 84)
.background(item.isPrimary ? AppColors.brandSoft : AppColors.backgroundLight)
.clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous))
}
}
struct AddressListItem: Identifiable {
let id = UUID()
let title: String
let detail: String
let icon: String
let isPrimary: Bool
}

View File

@@ -0,0 +1,448 @@
import SwiftUI
struct AddressesView: View {
let message: String?
@Binding var appState: AppState
var selectionMode: Bool = false
@Environment(\.dismiss) var dismiss
@State var isLoading = false
@State var errorMessage: String? = nil
@State var addresses: [CustomerAddress] = []
@State var openAddAddressForm = false
@State var editingAddress: CustomerAddress? = nil
@State var openSwipeRowId: String? = nil
@State var deletingRowId: String? = nil
@State var settingDefaultRowId: String? = nil
let tabBarClearance: CGFloat = 96
var body: some View {
ZStack {
AppColors.backgroundLight
.ignoresSafeArea()
ScrollView(showsIndicators: false) {
VStack(spacing: 20) {
screenHeader(title: "Meus Endereços", onBack: { dismiss() })
if let message {
Text(message)
.font(AppTypography.body)
.foregroundStyle(AppColors.textPrimary)
.multilineTextAlignment(.center)
.padding(.horizontal, 20)
.padding(.vertical, 14)
.frame(maxWidth: .infinity, alignment: .center)
.background(AppColors.brandSoft)
.clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous))
}
VStack(spacing: 16) {
if isLoading {
ProgressView()
.padding(.top, 24)
} else if let errorMessage {
Text(errorMessage)
.font(AppTypography.body)
.foregroundStyle(AppColors.textMuted)
.multilineTextAlignment(.center)
.padding(.top, 24)
} else if addresses.isEmpty {
Text("Nenhum endereço cadastrado")
.font(AppTypography.body)
.foregroundStyle(AppColors.textMuted)
.padding(.top, 24)
} else {
ForEach(Array(addresses.enumerated()), id: \.offset) { index, address in
let rowId = addressRowId(for: address, index: index)
let isPrimary: Bool = {
if address.isDefault == true { return true }
if addresses.contains(where: { $0.isDefault == true }) { return false }
if let selectedId = appState.address.selectedId {
return address.id == selectedId
}
return index == 0
}()
if selectionMode {
Button {
selectAddress(address)
} label: {
AddressCard(item: addressToListItem(address, isPrimary: isPrimary))
}
.buttonStyle(.plain)
} else if addresses.count > 1 {
SwipeToDeleteAddressRow(
rowId: rowId,
openRowId: $openSwipeRowId,
isDeleting: deletingRowId == rowId,
onDelete: { deleteAddress(address, rowId: rowId) }
) {
AddressCard(
item: addressToListItem(address, isPrimary: isPrimary),
onEdit: { beginEditing(address) },
onSetDefault: { setDefaultAddress(address, rowId: rowId) }
)
.appContentShape(Rectangle())
.simultaneousGesture(TapGesture().onEnded {
if openSwipeRowId == rowId { openSwipeRowId = nil }
})
.opacity(settingDefaultRowId == rowId ? 0.6 : 1.0)
}
.id(rowId)
.opacity(deletingRowId == rowId ? 0.6 : 1.0)
.disabled(deletingRowId != nil || settingDefaultRowId != nil)
} else {
AddressCard(
item: addressToListItem(address, isPrimary: isPrimary),
onEdit: { beginEditing(address) }
)
.overlay(alignment: .bottom) {
Text("Ao menos um endereço deve permanecer")
.font(AppTypography.caption)
.foregroundStyle(AppColors.textMuted.opacity(0.7))
.padding(.bottom, 8)
}
}
}
}
}
}
.padding(.horizontal, 20)
.padding(.top, 18)
}
VStack {
Spacer()
bottomOverlay
.padding(.bottom, tabBarClearance)
}
}
.navigationBarBackButtonHidden(true)
.appHiddenNavigationBar()
.sheet(isPresented: $openAddAddressForm) {
NavigationStack {
AddAddressFormView(existingAddress: editingAddress) { updatedAddresses, wasEditing in
addresses = updatedAddresses
applyPreferredAddress(from: updatedAddresses)
let title = wasEditing ? "Endereço atualizado com sucesso." : "Endereço adicionado com sucesso."
SnackbarCenter.shared.show(title: title, style: .success, icon: "checkmark.seal.fill", duration: 3.0)
}
}
}
.onChange(of: openAddAddressForm) { _, isOpen in
if isOpen == false {
editingAddress = nil
}
}
.onAppear {
if isLoading == false, addresses.isEmpty {
Task {
await loadAddresses()
}
}
}
}
var bottomOverlay: some View {
ZStack(alignment: .bottom) {
Rectangle()
.fill(AppColors.backgroundLight)
.frame(height: 136)
Button(action: {
editingAddress = nil
openAddAddressForm = true
}) {
HStack(spacing: 12) {
Image(systemName: "mappin.circle.fill")
.font(.system(size: 24))
Text("Adicionar novo endereço")
.font(AppTypography.heading3)
}
.foregroundStyle(AppColors.textInverse)
.frame(maxWidth: .infinity)
.padding(.vertical, 18)
.background(AppColors.primary)
.clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusXL, style: .continuous))
}
.buttonStyle(.plain)
.padding(.horizontal, 20)
.padding(.bottom, 14)
}
}
private func screenHeader(title: String, onBack: @escaping () -> Void) -> some View {
ZStack {
Text(title)
.font(AppTypography.heading2)
.foregroundStyle(AppColors.textPrimary)
HStack {
Button(action: onBack) {
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()
}
}
}
private func selectAddress(_ address: CustomerAddress) {
appState.address.selectedId = address.id
let label = (address.label ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
appState.address.display = label.isEmpty ? "Defina seu endereco" : label
if let lat = address.latLong?.first, let lng = address.latLong?.dropFirst().first {
appState.address.latitude = lat
appState.address.longitude = lng
} else {
appState.address.latitude = nil
appState.address.longitude = nil
}
SessionStateStore.saveAddress(appState.address)
if selectionMode {
dismiss()
}
}
private func beginEditing(_ address: CustomerAddress) {
openSwipeRowId = nil
editingAddress = address
openAddAddressForm = true
}
private func applyPreferredAddress(from updatedAddresses: [CustomerAddress]) {
let selected = resolvePreferredAddress(from: updatedAddresses)
appState.address.selectedId = selected?.id
appState.address.display = selected?.label?.isEmpty == false ? (selected?.label ?? "Defina seu endereco") : "Defina seu endereco"
if let lat = selected?.latLong?.first, let lng = selected?.latLong?.dropFirst().first {
appState.address.latitude = lat
appState.address.longitude = lng
} else {
appState.address.latitude = nil
appState.address.longitude = nil
}
SessionStateStore.saveAddress(appState.address)
}
private func addressRowId(for address: CustomerAddress, index: Int) -> String {
if let id = address.id, id.isEmpty == false {
return "addr:\(id)"
}
return "idx:\(index):\(address.label ?? ""):\(address.address ?? ""):\(address.number ?? "")"
}
private func setDefaultAddress(_ address: CustomerAddress, rowId: String) {
guard settingDefaultRowId == nil else { return }
settingDefaultRowId = rowId
openSwipeRowId = nil
Task {
var resolvedId = address.id?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if resolvedId.isEmpty {
if let book = try? await ApiService().profile(forceRefresh: true).result?.addressBook {
await MainActor.run { addresses = book }
resolvedId = book.first {
$0.address == address.address &&
$0.number == address.number &&
$0.zipCode == address.zipCode
}?.id?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
}
}
guard resolvedId.isEmpty == false else {
await MainActor.run {
settingDefaultRowId = nil
SnackbarCenter.shared.show(title: "Não foi possível identificar o endereço.", style: .error, icon: "xmark.octagon.fill", duration: 3.5)
}
return
}
do {
let response = try await ApiService().setDefaultAddress(addressId: resolvedId)
await MainActor.run {
settingDefaultRowId = nil
if response.error == false {
addresses = addresses.map { addr in
let isTarget = (addr.id ?? "") == resolvedId
return CustomerAddress(
id: addr.id, label: addr.label, address: addr.address,
number: addr.number, complement: addr.complement,
neighborhood: addr.neighborhood, city: addr.city,
state: addr.state, zipCode: addr.zipCode,
latLong: addr.latLong, isDefault: isTarget
)
}
selectAddress(address)
SnackbarCenter.shared.show(title: "Endereço principal atualizado.", style: .success, icon: "star.fill", duration: 2.5)
} else {
SnackbarCenter.shared.show(title: response.message ?? "Não foi possível definir endereço principal.", style: .error, icon: "xmark.octagon.fill", duration: 3.0)
}
}
} catch {
await MainActor.run {
settingDefaultRowId = nil
SnackbarCenter.shared.show(title: "Erro ao atualizar endereço principal.", style: .error, icon: "xmark.octagon.fill", duration: 3.0)
}
}
}
}
private func deleteAddress(_ address: CustomerAddress, rowId: String) {
guard addresses.count > 1, deletingRowId == nil else { return }
deletingRowId = rowId
openSwipeRowId = nil
Task {
do {
let response = try await ApiService().deleteCustomerAddress(address)
await MainActor.run {
deletingRowId = nil
guard response.error == false else {
let message = response.message ?? "Não foi possível excluir o endereço."
SnackbarCenter.shared.show(title: message, style: .error, icon: "xmark.octagon.fill", duration: 4.0)
return
}
let updatedAddresses = response.result?.addressBook ?? []
addresses = updatedAddresses
applyPreferredAddress(from: updatedAddresses)
SnackbarCenter.shared.show(title: "Endereço removido com sucesso.", style: .success, icon: "checkmark.seal.fill", duration: 3.0)
}
} catch {
await MainActor.run {
deletingRowId = nil
let message = error.localizedDescription
SnackbarCenter.shared.show(title: message, style: .error, icon: "xmark.octagon.fill", duration: 4.0)
}
}
}
}
func addressToListItem(_ address: CustomerAddress, isPrimary: Bool) -> AddressListItem {
let title = address.label?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false ? (address.label ?? "Endereço") : "Endereço"
let line1 = [address.address, address.number]
.compactMap { $0?.trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { $0.isEmpty == false }
.joined(separator: ", ")
let line2 = [address.neighborhood, address.city, address.state]
.compactMap { $0?.trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { $0.isEmpty == false }
.joined(separator: ", ")
let detail = [line1, line2]
.filter { $0.isEmpty == false }
.joined(separator: " - ")
return AddressListItem(
title: title,
detail: detail.isEmpty ? "Endereço sem detalhes" : detail,
icon: iconName(for: title),
isPrimary: isPrimary
)
}
func iconName(for label: String) -> String {
let normalized = label.folding(options: [.diacriticInsensitive, .caseInsensitive], locale: .current)
if normalized.contains("casa") {
return "house.fill"
}
if normalized.contains("trabalho") {
return "briefcase.fill"
}
return "mappin.and.ellipse"
}
@MainActor
func loadAddresses() async {
isLoading = true
errorMessage = nil
do {
let service = ApiService()
let response = try await service.profile(forceRefresh: true)
guard response.error == false else {
errorMessage = response.message ?? "Não foi possível carregar os endereços."
isLoading = false
return
}
if let customer = response.result {
appState.profile.id = customer.id
appState.profile.name = customer.name
appState.profile.email = customer.email
appState.profile.phone = customer.phoneNumber ?? appState.profile.phone
appState.profile.profilePicture = customer.profilePicture ?? ""
appState.profile.notificationsEnabled = customer.notificationsEnabled ?? false
appState.profile.faceIdEnabled = customer.faceIdEnabled ?? false
SessionStateStore.setActiveUserKey(
SessionStateStore.makeUserKey(profileId: customer.id, email: customer.email)
)
addresses = customer.addressBook ?? []
} else {
addresses = []
}
if let selected = resolvePreferredAddress(from: addresses) {
appState.address.selectedId = selected.id
appState.address.display = selected.label ?? "Defina seu endereco"
if let lat = selected.latLong?.first, let lng = selected.latLong?.dropFirst().first {
appState.address.latitude = lat
appState.address.longitude = lng
}
SessionStateStore.saveAddress(appState.address)
}
} catch {
errorMessage = error.localizedDescription
}
isLoading = false
}
private func resolvePreferredAddress(from list: [CustomerAddress]) -> CustomerAddress? {
guard list.isEmpty == false else { return nil }
let selectedId = appState.address.selectedId?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if selectedId.isEmpty == false,
let byId = list.first(where: { ($0.id ?? "").trimmingCharacters(in: .whitespacesAndNewlines) == selectedId }) {
return byId
}
let normalizedDisplay = appState.address.display
.trimmingCharacters(in: .whitespacesAndNewlines)
.folding(options: [.diacriticInsensitive, .caseInsensitive], locale: .current)
.lowercased()
if normalizedDisplay.isEmpty == false && normalizedDisplay != "defina seu endereco",
let byLabel = list.first(where: {
(($0.label ?? "")
.trimmingCharacters(in: .whitespacesAndNewlines)
.folding(options: [.diacriticInsensitive, .caseInsensitive], locale: .current)
.lowercased()) == normalizedDisplay
}) {
return byLabel
}
if let lat = appState.address.latitude, let lng = appState.address.longitude,
let byCoordinate = list.first(where: { address in
guard let addrLat = address.latLong?.first,
let addrLng = address.latLong?.dropFirst().first else { return false }
return abs(addrLat - lat) < 0.00001 && abs(addrLng - lng) < 0.00001
}) {
return byCoordinate
}
return list.first
}
}

View File

@@ -0,0 +1,407 @@
import Foundation
import SwiftUI
struct CartView: View {
@Binding var appState: AppState
@Binding var selectedTab: MainTab
let enterAuth: () -> Void
@State var openCheckout = false
@State var couponCode = ""
@State var appliedCouponCode: String? = nil
@State var deliveryFee: Double? = nil
@State var selectedCustomerAddress: CustomerAddress? = nil
@State var isLoadingDeliveryFee = false
var body: some View {
ScrollView(showsIndicators: false) {
VStack(spacing: 16) {
Text("Meu Carrinho")
.font(AppTypography.heading1)
.foregroundStyle(AppColors.textPrimary)
.frame(maxWidth: .infinity, alignment: .center)
.padding(.top, 20)
if appState.cart.items.isEmpty {
VStack(spacing: 10) {
Text("Seu carrinho está vazio")
.font(AppTypography.heading3)
.foregroundStyle(AppColors.textPrimary)
Text("Adicione produtos para continuar.")
.font(AppTypography.body)
.foregroundStyle(AppColors.textMuted)
}
.frame(maxWidth: .infinity, minHeight: 280, alignment: .center)
} else {
VStack(spacing: 12) {
ForEach(appState.cart.items) { item in
cartItemRow(item)
}
}
.padding(.horizontal, 20)
// couponSection
// .padding(.horizontal, 20)
summarySection
.padding(.horizontal, 20)
}
}
.padding(.bottom, 120)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(AppColors.backgroundLight)
.navigationDestination(isPresented: $openCheckout) {
CheckoutView(appState: $appState, selectedTab: $selectedTab)
}
.task(id: deliveryFeeWatchKey) {
await refreshDeliveryFee()
}
}
private var subtotalValue: Double {
appState.cart.items.reduce(0) { $0 + (Double($1.quantity) * $1.unitPrice) }
}
private var totalValue: Double {
max(0, subtotalValue + (deliveryFee ?? 0) - effectiveDiscountValue)
}
private var effectiveDiscountValue: Double {
let normalizedCoupon = (appliedCouponCode ?? "")
.trimmingCharacters(in: .whitespacesAndNewlines)
.uppercased()
if normalizedCoupon == "DESCONTO10" {
return min(subtotalValue, subtotalValue * 0.1)
}
return 0
}
private var discountLabelValue: String {
if effectiveDiscountValue <= 0.0001 {
return formatCurrency(0)
}
return "-\(formatCurrency(effectiveDiscountValue))"
}
private var deliveryFeeWatchKey: String {
let storeId = appState.cart.storeId ?? "nil"
let selectedId = appState.address.selectedId ?? "nil"
let display = appState.address.display.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
let lat = appState.address.latitude.map { String(format: "%.5f", $0) } ?? "nil"
let lng = appState.address.longitude.map { String(format: "%.5f", $0) } ?? "nil"
return "\(storeId)|\(selectedId)|\(display)|\(lat)|\(lng)"
}
private var couponSection: some View {
VStack(alignment: .leading, spacing: 10) {
Text("Cupom de Desconto")
.font(AppTypography.heading2)
.foregroundStyle(AppColors.textPrimary)
HStack(spacing: 10) {
HStack(spacing: 8) {
Image(systemName: "ticket")
.foregroundStyle(AppColors.textMuted)
TextField("Inserir cupom", text: $couponCode)
.appNoAutoCap()
}
.padding(.horizontal, 12)
.frame(height: 50)
.background(AppColors.surface)
.overlay(
RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous)
.stroke(AppColors.brandSoft, lineWidth: 1)
)
.clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous))
Button("Aplicar") {
applyCoupon()
}
.font(AppTypography.heading3)
.foregroundStyle(AppColors.textInverse)
.frame(width: 120, height: 50)
.background(AppColors.brandDark)
.clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous))
.buttonStyle(.plain)
}
if let appliedCouponCode {
Text("Cupom aplicado: \(appliedCouponCode)")
.font(.caption)
.foregroundStyle(AppColors.primary)
}
}
}
private var summarySection: some View {
VStack(alignment: .leading, spacing: 12) {
Text("Resumo de Valores")
.font(AppTypography.heading2)
.foregroundStyle(AppColors.textPrimary)
summaryRow(title: "Subtotal", value: formatCurrency(subtotalValue))
summaryRow(title: "Taxa de Entrega", value: deliveryFeeLabel)
summaryRow(title: "Desconto", value: discountLabelValue, valueColor: effectiveDiscountValue > 0 ? Color.red : AppColors.textMuted)
Divider()
summaryRow(title: "Total", value: formatCurrency(totalValue), highlighted: true)
Button {
guard appState.session.isAuthenticated else {
enterAuth()
return
}
openCheckout = true
} label: {
HStack(spacing: 10) {
Text("Ir para o Pagamento")
.font(AppTypography.heading2)
Image(systemName: "arrow.right")
.font(.system(size: 18, weight: .bold))
}
.foregroundStyle(AppColors.textInverse)
.frame(maxWidth: .infinity, minHeight: 54)
.background(AppColors.primary)
.clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous))
}
.buttonStyle(.plain)
}
.padding(16)
.background(AppColors.surface)
.clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusXL, style: .continuous))
}
private var deliveryFeeLabel: String {
if isLoadingDeliveryFee {
return "Calculando..."
}
if let deliveryFee {
return formatCurrency(deliveryFee)
}
return "Indisponível"
}
private func summaryRow(title: String, value: String, valueColor: Color? = nil, highlighted: Bool = false) -> some View {
HStack {
Text(title)
.font(highlighted ? AppTypography.heading2 : AppTypography.body)
.foregroundStyle(AppColors.textMuted)
Spacer()
Text(value)
.font(highlighted ? AppTypography.heading1 : AppTypography.heading3)
.foregroundStyle(valueColor ?? AppColors.textPrimary)
}
}
private func cartItemRow(_ item: CartItemState) -> some View {
HStack(spacing: 14) {
AsyncStoreImage(imageURL: item.imageURL)
.frame(width: 78, height: 78)
.clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous))
VStack(alignment: .leading, spacing: 6) {
Text(item.name)
.font(AppTypography.heading3)
.foregroundStyle(AppColors.textPrimary)
.lineLimit(1)
if let details = item.details, details.isEmpty == false {
Text(details)
.font(.caption)
.foregroundStyle(AppColors.textMuted)
.lineLimit(2)
}
Text(formatCurrency(item.unitPrice))
.font(AppTypography.body)
.foregroundStyle(AppColors.textMuted)
}
Spacer()
HStack(spacing: 10) {
Button(action: { appState.cart.decrement(itemId: item.id) }) {
Image(systemName: "minus")
.font(.system(size: 13, weight: .bold))
.foregroundStyle(AppColors.textPrimary)
.frame(width: 32, height: 32)
.background(AppColors.backgroundLight)
.clipShape(Circle())
}
.buttonStyle(.plain)
Text("\(item.quantity)")
.font(AppTypography.heading3)
.foregroundStyle(AppColors.textPrimary)
.frame(minWidth: 18)
Button(action: { appState.cart.increment(itemId: item.id) }) {
Image(systemName: "plus")
.font(.system(size: 13, weight: .bold))
.foregroundStyle(AppColors.textPrimary)
.frame(width: 32, height: 32)
.background(AppColors.tertiary)
.clipShape(Circle())
}
.buttonStyle(.plain)
}
.padding(.horizontal, 8)
.padding(.vertical, 6)
.background(AppColors.backgroundLight)
.clipShape(Capsule())
}
.padding(.horizontal, 12)
.padding(.vertical, 12)
.background(AppColors.surface)
.clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous))
}
private func applyCoupon() {
let normalized = couponCode.trimmingCharacters(in: .whitespacesAndNewlines).uppercased()
guard normalized.isEmpty == false else {
appliedCouponCode = nil
return
}
if normalized == "DESCONTO10" {
appliedCouponCode = normalized
return
}
appliedCouponCode = nil
}
private func formatCurrency(_ value: Double) -> String {
String(format: "R$ %.2f", value).replacingOccurrences(of: ".", with: ",")
}
@MainActor
private func refreshDeliveryFee() async {
guard appState.cart.items.isEmpty == false else {
deliveryFee = nil
selectedCustomerAddress = nil
return
}
guard let storeId = appState.cart.storeId, storeId.isEmpty == false else {
deliveryFee = nil
selectedCustomerAddress = nil
return
}
isLoadingDeliveryFee = true
defer { isLoadingDeliveryFee = false }
do {
let profileResponse = try await ApiService().profile(forceRefresh: true)
let addresses = profileResponse.result?.addressBook ?? []
if let selectedId = appState.address.selectedId, selectedId.isEmpty == false {
selectedCustomerAddress = addresses.first(where: { $0.id == selectedId })
} else {
selectedCustomerAddress = nil
}
// id can be nil for some address book entries lat/lng is set
// immediately and reliably at selection time (AddressesView.
// selectAddress), so it's a stronger signal than the label match
// below, which silently collides whenever two addresses share an
// empty/duplicate label. Without this, an id-less address falls
// through to addresses.first and never actually "changes".
if selectedCustomerAddress == nil,
let lat = appState.address.latitude, let lng = appState.address.longitude {
selectedCustomerAddress = addresses.first { address in
guard let addrLat = address.latLong?.first,
let addrLng = address.latLong?.dropFirst().first else { return false }
return abs(addrLat - lat) < 0.00001 && abs(addrLng - lng) < 0.00001
}
}
if selectedCustomerAddress == nil {
let display = appState.address.display
.trimmingCharacters(in: .whitespacesAndNewlines)
.lowercased()
if display.isEmpty == false, display != "defina seu endereco" {
selectedCustomerAddress = addresses.first {
($0.label ?? "").trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == display
}
}
}
if selectedCustomerAddress == nil {
selectedCustomerAddress = addresses.first
}
if let selected = selectedCustomerAddress {
// appState.address reflects the address the user just picked
// authoritative. Only fill in gaps from the address book here,
// never overwrite a live selection with a (possibly stale)
// cached record, or the fee/validation payload below can end
// up built against the wrong coordinates.
if appState.address.selectedId == nil || appState.address.selectedId?.isEmpty == true {
appState.address.selectedId = selected.id
}
if appState.address.display.isEmpty || appState.address.display == "Defina seu endereco" {
let label = (selected.label ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
if label.isEmpty == false {
appState.address.display = label
}
}
if appState.address.latitude == nil || appState.address.longitude == nil,
let lat = selected.latLong?.first, let lng = selected.latLong?.dropFirst().first {
appState.address.latitude = lat
appState.address.longitude = lng
}
SessionStateStore.saveAddress(appState.address)
}
var payloadLat = appState.address.latitude ?? selectedCustomerAddress?.latLong?.first
var payloadLng = appState.address.longitude ?? selectedCustomerAddress?.latLong?.dropFirst().first
// Some saved addresses have no lat/long (CEP lookup at creation
// time didn't return coordinates). Without coordinates the
// backend can't tell this address apart from any other, so the
// fee silently never changes. Geocode locally as a fallback.
if payloadLat == nil || payloadLng == nil {
if let coordinate = await LocationService.geocodeAddress(
street: selectedCustomerAddress?.address,
number: selectedCustomerAddress?.number,
neighborhood: selectedCustomerAddress?.neighborhood,
city: selectedCustomerAddress?.city,
state: selectedCustomerAddress?.state,
zip: selectedCustomerAddress?.zipCode
) {
payloadLat = coordinate.0
payloadLng = coordinate.1
appState.address.latitude = coordinate.0
appState.address.longitude = coordinate.1
SessionStateStore.saveAddress(appState.address)
}
}
let payload = ValidateDeliveryAddressPayload(
address: ValidateDeliveryAddressDataPayload(
street: selectedCustomerAddress?.address,
number: selectedCustomerAddress?.number,
neighborhood: selectedCustomerAddress?.neighborhood,
city: selectedCustomerAddress?.city,
state: selectedCustomerAddress?.state,
zip: selectedCustomerAddress?.zipCode,
lat: payloadLat,
lng: payloadLng
)
)
let validationResponse = try await ApiService().validateDeliveryAddress(storeId: storeId, payload: payload)
guard validationResponse.error == false,
validationResponse.result?.deliveryAllowed == true,
let fee = validationResponse.result?.deliveryFee else {
deliveryFee = nil
return
}
deliveryFee = fee
} catch {
deliveryFee = nil
}
}
}

Some files were not shown because too many files have changed in this diff Show More