Commit
70
Sources/PediFoods/Components/Buttons.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
216
Sources/PediFoods/Components/CachedRemoteImage.swift
Normal file
@@ -0,0 +1,216 @@
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
#if canImport(UIKit)
|
||||
import UIKit
|
||||
#elseif canImport(AppKit)
|
||||
import AppKit
|
||||
#endif
|
||||
|
||||
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
|
||||
|
||||
#if canImport(UIKit) || canImport(AppKit)
|
||||
@StateObject var loader = CachedRemoteImageLoader()
|
||||
#endif
|
||||
|
||||
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 canImport(UIKit)
|
||||
if let uiImage = loader.uiImage {
|
||||
rendered(image: Image(uiImage: uiImage), pixelSize: uiImage.size, in: geometry.size)
|
||||
} else {
|
||||
placeholder
|
||||
}
|
||||
#elseif canImport(AppKit)
|
||||
if let nsImage = loader.nsImage {
|
||||
rendered(image: Image(nsImage: nsImage), pixelSize: nsImage.size, in: geometry.size)
|
||||
} else {
|
||||
placeholder
|
||||
}
|
||||
#else
|
||||
if let imageURL,
|
||||
let url = URL(string: imageURL) {
|
||||
AsyncImage(url: url) { phase in
|
||||
switch phase {
|
||||
case .success(let image):
|
||||
image
|
||||
.resizable()
|
||||
.scaledToFill()
|
||||
default:
|
||||
placeholder
|
||||
}
|
||||
}
|
||||
} else {
|
||||
placeholder
|
||||
}
|
||||
#endif
|
||||
}
|
||||
.frame(width: geometry.size.width, height: geometry.size.height)
|
||||
.clipped()
|
||||
}
|
||||
.onAppear {
|
||||
#if canImport(UIKit) || canImport(AppKit)
|
||||
loader.load(imageURL: imageURL, ttl: ttl)
|
||||
#endif
|
||||
}
|
||||
.onChange(of: imageURL) { _, newValue in
|
||||
#if canImport(UIKit) || canImport(AppKit)
|
||||
loader.load(imageURL: newValue, ttl: ttl)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
#if canImport(UIKit) || canImport(AppKit)
|
||||
@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)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
#if canImport(UIKit) || canImport(AppKit)
|
||||
@MainActor
|
||||
final class CachedRemoteImageLoader: ObservableObject {
|
||||
#if canImport(UIKit)
|
||||
@Published var uiImage: UIImage?
|
||||
#elseif canImport(AppKit)
|
||||
@Published var nsImage: NSImage?
|
||||
#endif
|
||||
|
||||
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()
|
||||
|
||||
#if canImport(UIKit)
|
||||
uiImage = nil
|
||||
#elseif canImport(AppKit)
|
||||
nsImage = nil
|
||||
#endif
|
||||
|
||||
guard let normalized, normalized.isEmpty == false else { return }
|
||||
|
||||
#if canImport(UIKit) || canImport(AppKit)
|
||||
let dataCacheKey = Self.dataURLCacheKey(normalized)
|
||||
if let cachedDataImage: PlatformImage = AppContentCache.shared.value(for: dataCacheKey, as: PlatformImage.self) {
|
||||
#if canImport(UIKit)
|
||||
uiImage = cachedDataImage
|
||||
#elseif canImport(AppKit)
|
||||
nsImage = cachedDataImage
|
||||
#endif
|
||||
return
|
||||
}
|
||||
|
||||
if let image = Self.imageFromDataURL(normalized) {
|
||||
AppContentCache.shared.set(image, for: dataCacheKey, ttl: ttl)
|
||||
#if canImport(UIKit)
|
||||
uiImage = image
|
||||
#elseif canImport(AppKit)
|
||||
nsImage = image
|
||||
#endif
|
||||
return
|
||||
}
|
||||
#endif
|
||||
|
||||
guard let url = URL(string: normalized) else { return }
|
||||
|
||||
task = Task { [weak self] in
|
||||
#if canImport(UIKit) || canImport(AppKit)
|
||||
let image = await AppImageCache.shared.image(for: url, ttl: ttl)
|
||||
guard Task.isCancelled == false else { return }
|
||||
await MainActor.run {
|
||||
#if canImport(UIKit)
|
||||
self?.uiImage = image
|
||||
#elseif canImport(AppKit)
|
||||
self?.nsImage = image
|
||||
#endif
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
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)"
|
||||
}
|
||||
|
||||
#if canImport(UIKit) || canImport(AppKit)
|
||||
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 }
|
||||
#if canImport(UIKit)
|
||||
return UIImage(data: data)
|
||||
#elseif canImport(AppKit)
|
||||
return NSImage(data: data)
|
||||
#else
|
||||
return nil
|
||||
#endif
|
||||
}
|
||||
#endif
|
||||
}
|
||||
#endif
|
||||
43
Sources/PediFoods/Components/Inputs.swift
Normal 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)
|
||||
)
|
||||
}
|
||||
}
|
||||
38
Sources/PediFoods/Components/SnackbarOverlay.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
173
Sources/PediFoods/Components/StoreCard.swift
Normal 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]
|
||||
}
|
||||
25
Sources/PediFoods/Components/TextFieldCompat.swift
Normal 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
|
||||
}
|
||||
}
|
||||
431
Sources/PediFoods/ContentView.swift
Normal file
@@ -0,0 +1,431 @@
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
struct ContentView: View {
|
||||
@State var root: RootFlow = DefaultTokenStore().jwt == nil ? .auth : .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?
|
||||
@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, root: $root, tokenStore: tokenStore, appState: $appState)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
.onDisappear {
|
||||
detachCartResetObserver()
|
||||
detachAppResumeObserver()
|
||||
detachSessionExpiredObserver()
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// Always refresh profile when authenticated.
|
||||
// This keeps profile/address/cart scope consistent after relogin
|
||||
// and avoids stale local state during checkout payload generation.
|
||||
do {
|
||||
let response = try await ApiService().profile()
|
||||
if response.error == false, let customer = response.result {
|
||||
hydrateAppState(with: customer)
|
||||
}
|
||||
} 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
|
||||
}
|
||||
|
||||
@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
|
||||
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 {
|
||||
AddressesView(
|
||||
message: appState.address.onboardingMessage,
|
||||
appState: $appState,
|
||||
selectionMode: true
|
||||
)
|
||||
.onAppear {
|
||||
appState.address.onboardingMessage = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
10
Sources/PediFoods/Coordinators/AppCoordinator.swift
Normal file
@@ -0,0 +1,10 @@
|
||||
enum RootFlow: Hashable {
|
||||
case auth
|
||||
case main
|
||||
}
|
||||
|
||||
enum MainTab: Hashable {
|
||||
case home
|
||||
case cart
|
||||
case profile
|
||||
}
|
||||
135
Sources/PediFoods/DesignSystem/DesignSystem.swift
Normal file
@@ -0,0 +1,135 @@
|
||||
import SwiftUI
|
||||
#if canImport(UIKit)
|
||||
import UIKit
|
||||
#elseif canImport(AppKit)
|
||||
import AppKit
|
||||
#endif
|
||||
|
||||
// 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 {
|
||||
#if canImport(UIKit)
|
||||
return UIFont(name: fontFamily, size: 16) != nil
|
||||
#elseif canImport(AppKit)
|
||||
return NSFont(name: fontFamily, size: 16) != nil
|
||||
#else
|
||||
return false
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
30
Sources/PediFoods/PediFoodsApp.swift
Normal file
@@ -0,0 +1,30 @@
|
||||
import Foundation
|
||||
import OSLog
|
||||
import SwiftUI
|
||||
|
||||
let logger: Logger = Logger(subsystem: "com.br.pedifoods.app", category: "PediFoods")
|
||||
|
||||
public struct PediFoodsRootView: View {
|
||||
public init() {}
|
||||
|
||||
public var body: some View {
|
||||
ContentView()
|
||||
}
|
||||
}
|
||||
|
||||
public final class PediFoodsAppDelegate: Sendable {
|
||||
public static let shared = PediFoodsAppDelegate()
|
||||
|
||||
private init() {}
|
||||
|
||||
public func onInit() { logger.debug("onInit") }
|
||||
public func onLaunch() { logger.debug("onLaunch") }
|
||||
public func onResume() {
|
||||
logger.debug("onResume")
|
||||
NotificationCenter.default.post(name: .appDidResume, object: nil)
|
||||
}
|
||||
public func onPause() { logger.debug("onPause") }
|
||||
public func onStop() { logger.debug("onStop") }
|
||||
public func onDestroy() { logger.debug("onDestroy") }
|
||||
public func onLowMemory() { logger.debug("onLowMemory") }
|
||||
}
|
||||
1357
Sources/PediFoods/Resources/Localizable.xcstrings
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
BIN
Sources/PediFoods/Resources/alelocard_logo.png
Normal file
|
After Width: | Height: | Size: 3.7 KiB |
BIN
Sources/PediFoods/Resources/amexcard_logo.png
Normal file
|
After Width: | Height: | Size: 3.7 KiB |
BIN
Sources/PediFoods/Resources/hipercard_logo.png
Normal file
|
After Width: | Height: | Size: 5.2 KiB |
BIN
Sources/PediFoods/Resources/icon_pix.png
Normal file
|
After Width: | Height: | Size: 835 B |
BIN
Sources/PediFoods/Resources/login_dark.png
Normal file
|
After Width: | Height: | Size: 2.8 MiB |
BIN
Sources/PediFoods/Resources/login_light.png
Normal file
|
After Width: | Height: | Size: 2.8 MiB |
BIN
Sources/PediFoods/Resources/mastercard_logo.png
Normal file
|
After Width: | Height: | Size: 8.3 KiB |
|
After Width: | Height: | Size: 43 KiB |
|
After Width: | Height: | Size: 41 KiB |
136
Sources/PediFoods/Resources/pedifoods.icon/icon.json
Normal file
@@ -0,0 +1,136 @@
|
||||
{
|
||||
"fill-specializations" : [
|
||||
{
|
||||
"value" : {
|
||||
"automatic-gradient" : "srgb:0.99020,0.98039,1.00000,1.00000"
|
||||
}
|
||||
},
|
||||
{
|
||||
"appearance" : "dark",
|
||||
"value" : {
|
||||
"solid" : "srgb:0.00000,0.00000,0.00000,1.00000"
|
||||
}
|
||||
}
|
||||
],
|
||||
"groups" : [
|
||||
{
|
||||
"hidden" : false,
|
||||
"layers" : [
|
||||
{
|
||||
"glass-specializations" : [
|
||||
{
|
||||
"appearance" : "dark",
|
||||
"value" : true
|
||||
}
|
||||
],
|
||||
"hidden" : false,
|
||||
"image-name" : "Screenshot 2026-04-25 at 16.22.36.png",
|
||||
"name" : "Screenshot 2026-04-25 at 16.22.36",
|
||||
"opacity-specializations" : [
|
||||
{
|
||||
"value" : 0
|
||||
},
|
||||
{
|
||||
"appearance" : "dark",
|
||||
"value" : 1
|
||||
}
|
||||
],
|
||||
"position-specializations" : [
|
||||
{
|
||||
"idiom" : "iOS",
|
||||
"value" : {
|
||||
"scale" : 1.24,
|
||||
"translation-in-points" : [
|
||||
0,
|
||||
0
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"idiom" : "watchOS",
|
||||
"value" : {
|
||||
"scale" : 1.54,
|
||||
"translation-in-points" : [
|
||||
0,
|
||||
0
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"hidden" : false,
|
||||
"image-name" : "Screenshot 2026-04-25 at 16.22.59.png",
|
||||
"name" : "Screenshot 2026-04-25 at 16.22.59",
|
||||
"position-specializations" : [
|
||||
{
|
||||
"idiom" : "iOS",
|
||||
"value" : {
|
||||
"scale" : 1.24,
|
||||
"translation-in-points" : [
|
||||
0,
|
||||
0
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"idiom" : "watchOS",
|
||||
"value" : {
|
||||
"scale" : 1.54,
|
||||
"translation-in-points" : [
|
||||
0,
|
||||
0
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"opacity-specializations" : [
|
||||
{
|
||||
"appearance" : "dark",
|
||||
"value" : 1
|
||||
}
|
||||
],
|
||||
"shadow" : {
|
||||
"kind" : "neutral",
|
||||
"opacity" : 0.5
|
||||
},
|
||||
"translucency" : {
|
||||
"enabled" : true,
|
||||
"value" : 0.5
|
||||
}
|
||||
},
|
||||
{
|
||||
"blend-mode" : "normal",
|
||||
"hidden-specializations" : [
|
||||
{
|
||||
"value" : false
|
||||
},
|
||||
{
|
||||
"idiom" : "iOS",
|
||||
"value" : false
|
||||
}
|
||||
],
|
||||
"layers" : [
|
||||
|
||||
],
|
||||
"shadow" : {
|
||||
"kind" : "neutral",
|
||||
"opacity" : 0.5
|
||||
},
|
||||
"translucency" : {
|
||||
"enabled" : true,
|
||||
"value" : 0.5
|
||||
}
|
||||
}
|
||||
],
|
||||
"supported-platforms" : {
|
||||
"circles" : [
|
||||
"watchOS"
|
||||
],
|
||||
"squares" : [
|
||||
"iOS"
|
||||
]
|
||||
}
|
||||
}
|
||||
BIN
Sources/PediFoods/Resources/pedifoods.png
Normal file
|
After Width: | Height: | Size: 82 KiB |
BIN
Sources/PediFoods/Resources/pedifoods_dark.png
Normal file
|
After Width: | Height: | Size: 82 KiB |
BIN
Sources/PediFoods/Resources/pin_image_app.png
Normal file
|
After Width: | Height: | Size: 1.8 MiB |
BIN
Sources/PediFoods/Resources/pin_image_app_dark.png
Normal file
|
After Width: | Height: | Size: 1.9 MiB |
BIN
Sources/PediFoods/Resources/placeholder-pizza.png
Normal file
|
After Width: | Height: | Size: 667 KiB |
BIN
Sources/PediFoods/Resources/placeholder-product.png
Normal file
|
After Width: | Height: | Size: 59 KiB |
BIN
Sources/PediFoods/Resources/sodexo_logo.png
Normal file
|
After Width: | Height: | Size: 7.6 KiB |
BIN
Sources/PediFoods/Resources/tracking-canceled.png
Normal file
|
After Width: | Height: | Size: 406 KiB |
BIN
Sources/PediFoods/Resources/tracking-completed.png
Normal file
|
After Width: | Height: | Size: 924 KiB |
BIN
Sources/PediFoods/Resources/tracking-delivering.png
Normal file
|
After Width: | Height: | Size: 951 KiB |
BIN
Sources/PediFoods/Resources/tracking-pending.png
Normal file
|
After Width: | Height: | Size: 940 KiB |
BIN
Sources/PediFoods/Resources/tracking-preparing.png
Normal file
|
After Width: | Height: | Size: 926 KiB |
BIN
Sources/PediFoods/Resources/tracking-ready.png
Normal file
|
After Width: | Height: | Size: 799 KiB |
BIN
Sources/PediFoods/Resources/visacard_logo.png
Normal file
|
After Width: | Height: | Size: 4.1 KiB |
83
Sources/PediFoods/Services/ApiCardModels.swift
Normal 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?
|
||||
}
|
||||
488
Sources/PediFoods/Services/ApiClient.swift
Normal file
@@ -0,0 +1,488 @@
|
||||
import Foundation
|
||||
#if canImport(FoundationNetworking)
|
||||
import FoundationNetworking
|
||||
#endif
|
||||
#if canImport(LCEssentials) && os(iOS)
|
||||
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)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private 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?
|
||||
|
||||
init(path: String,
|
||||
method: String = "GET",
|
||||
module: ApiModule = .none,
|
||||
requiresAuth: Bool = true,
|
||||
queryItems: [URLQueryItem] = [],
|
||||
body: Data? = nil) {
|
||||
self.path = path
|
||||
self.method = method
|
||||
self.module = module
|
||||
self.requiresAuth = requiresAuth
|
||||
self.queryItems = queryItems
|
||||
self.body = body
|
||||
}
|
||||
}
|
||||
|
||||
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) && os(iOS)
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extension ApiClient {
|
||||
#if canImport(LCEssentials) && os(iOS)
|
||||
func sendWithLCEssentials<T: Decodable>(_ request: ApiRequest) async throws -> T {
|
||||
let urlString = try buildURL(path: request.path, query: request.queryItems).absoluteString
|
||||
let 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)
|
||||
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 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) && os(iOS)
|
||||
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]) throws -> URL {
|
||||
guard var components = URLComponents(url: ApiConfig.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
|
||||
}
|
||||
}
|
||||
41
Sources/PediFoods/Services/ApiConfig.swift
Normal file
@@ -0,0 +1,41 @@
|
||||
import Foundation
|
||||
|
||||
enum ApiModule: Sendable {
|
||||
case app
|
||||
case customer
|
||||
case store
|
||||
case resource
|
||||
case none
|
||||
}
|
||||
|
||||
enum ApiConfig {
|
||||
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")!
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
75
Sources/PediFoods/Services/ApiCustomerPayloadModels.swift
Normal file
@@ -0,0 +1,75 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
6
Sources/PediFoods/Services/ApiFavoriteModels.swift
Normal file
@@ -0,0 +1,6 @@
|
||||
import Foundation
|
||||
|
||||
struct CustomerFavoritesMutationResult: Decodable {
|
||||
let favorites: [String]
|
||||
let store: StoreSummary?
|
||||
}
|
||||
548
Sources/PediFoods/Services/ApiModels.swift
Normal file
@@ -0,0 +1,548 @@
|
||||
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]?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case name
|
||||
case email
|
||||
case phoneNumber
|
||||
case profilePicture
|
||||
case favorites
|
||||
case addressBook = "address_book"
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
196
Sources/PediFoods/Services/ApiOrderModels.swift
Normal 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))
|
||||
}
|
||||
}
|
||||
715
Sources/PediFoods/Services/ApiOrderTrackingModels.swift
Normal 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
|
||||
)
|
||||
}
|
||||
}
|
||||
80
Sources/PediFoods/Services/ApiPizzaModels.swift
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
268
Sources/PediFoods/Services/ApiReviewModels.swift
Normal 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 = []
|
||||
}
|
||||
}
|
||||
531
Sources/PediFoods/Services/ApiService.swift
Normal file
@@ -0,0 +1,531 @@
|
||||
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 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 isSessionExpiredEnvelope(envelope) {
|
||||
expireSession(envelope.message)
|
||||
throw ApiServiceError.sessionExpired(envelope.message)
|
||||
}
|
||||
return envelope
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
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)
|
||||
AppContentCache.shared.invalidate(prefix: profileCachePrefix)
|
||||
return envelope
|
||||
}
|
||||
|
||||
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)
|
||||
AppContentCache.shared.invalidate(prefix: profileCachePrefix)
|
||||
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)
|
||||
let scopedProfileKey = "\(profileCachePrefix)\(scopedCacheSuffix())"
|
||||
if envelope.error == false, envelope.result != nil {
|
||||
AppContentCache.shared.set(envelope, for: scopedProfileKey, ttl: AppCacheTTL.twoHours)
|
||||
} 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
|
||||
}
|
||||
|
||||
let req = ApiRequest(path: "/api/public/categories", method: "GET", module: .none, requiresAuth: false)
|
||||
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 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 {
|
||||
AppContentCache.shared.invalidate(prefix: profileCachePrefix)
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
154
Sources/PediFoods/Services/AppCache.swift
Normal file
@@ -0,0 +1,154 @@
|
||||
import Foundation
|
||||
#if canImport(UIKit)
|
||||
import UIKit
|
||||
typealias PlatformImage = UIImage
|
||||
#elseif canImport(AppKit)
|
||||
import AppKit
|
||||
typealias PlatformImage = NSImage
|
||||
#endif
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#if canImport(UIKit) || canImport(AppKit)
|
||||
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? {
|
||||
#if canImport(UIKit)
|
||||
return UIImage(data: data)
|
||||
#elseif canImport(AppKit)
|
||||
return NSImage(data: data)
|
||||
#else
|
||||
return nil
|
||||
#endif
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
#if !(canImport(UIKit) || canImport(AppKit))
|
||||
final class AppImageCache: @unchecked Sendable {
|
||||
static let shared = AppImageCache()
|
||||
|
||||
private init() {}
|
||||
|
||||
func invalidateAll() {}
|
||||
}
|
||||
#endif
|
||||
283
Sources/PediFoods/Services/FeatureControlService.swift
Normal file
@@ -0,0 +1,283 @@
|
||||
import Foundation
|
||||
#if canImport(FoundationNetworking)
|
||||
import FoundationNetworking
|
||||
#endif
|
||||
|
||||
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 {
|
||||
#if canImport(UIKit) || canImport(AppKit)
|
||||
let version = (Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String)?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if let version, version.isEmpty == false {
|
||||
return version
|
||||
}
|
||||
#endif
|
||||
return "0.0.0"
|
||||
}
|
||||
}
|
||||
44
Sources/PediFoods/Services/ImageSourceResolver.swift
Normal 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)"
|
||||
}
|
||||
}
|
||||
136
Sources/PediFoods/Services/LocationService.swift
Normal file
@@ -0,0 +1,136 @@
|
||||
import Foundation
|
||||
|
||||
#if os(iOS)
|
||||
import CoreLocation
|
||||
#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 = CLLocationManager()
|
||||
private var completion: ((LocationResult) -> Void)?
|
||||
#endif
|
||||
|
||||
override init() {
|
||||
super.init()
|
||||
#if os(iOS)
|
||||
manager.delegate = self
|
||||
manager.desiredAccuracy = kCLLocationAccuracyHundredMeters
|
||||
#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
|
||||
}
|
||||
|
||||
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
|
||||
253
Sources/PediFoods/Services/OrderRealtimeTracker.swift
Normal file
@@ -0,0 +1,253 @@
|
||||
import Foundation
|
||||
#if canImport(FoundationNetworking)
|
||||
import FoundationNetworking
|
||||
#endif
|
||||
|
||||
@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
|
||||
}
|
||||
8
Sources/PediFoods/Services/SessionEvents.swift
Normal 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")
|
||||
}
|
||||
441
Sources/PediFoods/Services/SessionStateStore.swift
Normal file
@@ -0,0 +1,441 @@
|
||||
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 {
|
||||
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."
|
||||
|
||||
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 {
|
||||
UserDefaults.standard.removeObject(forKey: activeUserKey)
|
||||
} else {
|
||||
UserDefaults.standard.set(trimmed, forKey: activeUserKey)
|
||||
}
|
||||
}
|
||||
|
||||
static func loadActiveUserKey() -> String? {
|
||||
let value = UserDefaults.standard.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 defaults = UserDefaults.standard
|
||||
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 }
|
||||
UserDefaults.standard.set(data, forKey: addressStorageKey(for: nil))
|
||||
}
|
||||
|
||||
static func clearAddress() {
|
||||
let defaults = UserDefaults.standard
|
||||
defaults.removeObject(forKey: addressStorageKey(for: nil))
|
||||
defaults.removeObject(forKey: legacyAddressKey)
|
||||
}
|
||||
|
||||
static func clearActiveUser() {
|
||||
UserDefaults.standard.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 defaults = UserDefaults.standard
|
||||
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 }
|
||||
UserDefaults.standard.set(data, forKey: cartStorageKey(for: nil))
|
||||
}
|
||||
|
||||
static func clearCart() {
|
||||
UserDefaults.standard.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 defaults = UserDefaults.standard
|
||||
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 }
|
||||
UserDefaults.standard.set(data, forKey: trackedOrdersStorageKey(for: nil))
|
||||
}
|
||||
|
||||
static func clearTrackedOrders() {
|
||||
UserDefaults.standard.removeObject(forKey: trackedOrdersStorageKey(for: nil))
|
||||
}
|
||||
|
||||
static func savePendingCartOrderId(_ orderId: String) {
|
||||
let clean = orderId.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard clean.isEmpty == false else {
|
||||
clearPendingCartOrder()
|
||||
return
|
||||
}
|
||||
UserDefaults.standard.set(clean, forKey: pendingCartOrderStorageKey(for: nil))
|
||||
}
|
||||
|
||||
static func loadPendingCartOrderId() -> String? {
|
||||
let value = UserDefaults.standard.string(forKey: pendingCartOrderStorageKey(for: nil))?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if let value, value.isEmpty == false {
|
||||
return value
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
static func clearPendingCartOrder() {
|
||||
UserDefaults.standard.removeObject(forKey: pendingCartOrderStorageKey(for: nil))
|
||||
}
|
||||
|
||||
static func loadOrderReviews() -> [OrderReviewRecord] {
|
||||
let key = orderReviewsStorageKey(for: nil)
|
||||
guard let data = UserDefaults.standard.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 }
|
||||
UserDefaults.standard.set(data, forKey: orderReviewsStorageKey(for: nil))
|
||||
}
|
||||
|
||||
static func loadOrderReviewDraft(orderId: String) -> OrderReviewDraftState? {
|
||||
let key = orderReviewDraftStorageKey(for: orderId, userKey: nil)
|
||||
guard let data = UserDefaults.standard.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 }
|
||||
UserDefaults.standard.set(data, forKey: key)
|
||||
}
|
||||
|
||||
static func clearOrderReviewDraft(orderId: String) {
|
||||
let key = orderReviewDraftStorageKey(for: orderId, userKey: nil)
|
||||
UserDefaults.standard.removeObject(forKey: key)
|
||||
}
|
||||
}
|
||||
280
Sources/PediFoods/Services/StoreCatalogNormalizer.swift
Normal 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
|
||||
}
|
||||
}
|
||||
91
Sources/PediFoods/Services/TokenStore.swift
Normal file
@@ -0,0 +1,91 @@
|
||||
import Foundation
|
||||
#if os(iOS)
|
||||
import Security
|
||||
#endif
|
||||
|
||||
protocol TokenStore: AnyObject {
|
||||
var jwt: String? { get set }
|
||||
func clear()
|
||||
}
|
||||
|
||||
final class DefaultTokenStore: TokenStore {
|
||||
private let key = "auth_jwt"
|
||||
private let defaults = UserDefaults.standard
|
||||
|
||||
var jwt: String? {
|
||||
get {
|
||||
#if os(iOS)
|
||||
if let keychainValue = loadKeychainValue(for: key) {
|
||||
return keychainValue
|
||||
}
|
||||
#endif
|
||||
return defaults.string(forKey: key)
|
||||
}
|
||||
set {
|
||||
#if os(iOS)
|
||||
if let newValue {
|
||||
saveKeychainValue(newValue, for: key)
|
||||
} else {
|
||||
deleteKeychainValue(for: key)
|
||||
}
|
||||
#endif
|
||||
defaults.set(newValue, forKey: key)
|
||||
}
|
||||
}
|
||||
|
||||
func clear() {
|
||||
#if os(iOS)
|
||||
deleteKeychainValue(for: key)
|
||||
#endif
|
||||
defaults.removeObject(forKey: key)
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
private var serviceName: String { "com.br.pedifoods.app.auth" }
|
||||
|
||||
private func saveKeychainValue(_ value: String, for key: String) {
|
||||
guard let data = value.data(using: .utf8) else { return }
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: serviceName,
|
||||
kSecAttrAccount as String: key
|
||||
]
|
||||
|
||||
SecItemDelete(query as CFDictionary)
|
||||
let attributes: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: serviceName,
|
||||
kSecAttrAccount as String: key,
|
||||
kSecValueData as String: data
|
||||
]
|
||||
SecItemAdd(attributes as CFDictionary, nil)
|
||||
}
|
||||
|
||||
private func loadKeychainValue(for key: String) -> String? {
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: serviceName,
|
||||
kSecAttrAccount as String: key,
|
||||
kSecReturnData as String: true,
|
||||
kSecMatchLimit as String: kSecMatchLimitOne
|
||||
]
|
||||
var result: AnyObject?
|
||||
let status = SecItemCopyMatching(query as CFDictionary, &result)
|
||||
guard status == errSecSuccess,
|
||||
let data = result as? Data,
|
||||
let value = String(data: data, encoding: .utf8) else {
|
||||
return nil
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
private func deleteKeychainValue(for key: String) {
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: serviceName,
|
||||
kSecAttrAccount as String: key
|
||||
]
|
||||
SecItemDelete(query as CFDictionary)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
285
Sources/PediFoods/State/AppState.swift
Normal file
@@ -0,0 +1,285 @@
|
||||
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
|
||||
}
|
||||
|
||||
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 = ""
|
||||
}
|
||||
|
||||
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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
94
Sources/PediFoods/State/SnackbarCenter.swift
Normal 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 {
|
||||
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)?
|
||||
}
|
||||
171
Sources/PediFoods/Support/PlatformCompat.swift
Normal file
@@ -0,0 +1,171 @@
|
||||
import Foundation
|
||||
#if canImport(SwiftUI)
|
||||
import SwiftUI
|
||||
#endif
|
||||
|
||||
#if canImport(CoreGraphics)
|
||||
import CoreGraphics
|
||||
#endif
|
||||
|
||||
#if canImport(UIKit)
|
||||
import UIKit
|
||||
#endif
|
||||
|
||||
#if canImport(AppKit)
|
||||
import AppKit
|
||||
#endif
|
||||
|
||||
#if canImport(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
|
||||
}
|
||||
}
|
||||
#else
|
||||
struct UIDevice {
|
||||
static let topNotch: CGFloat = 0.0
|
||||
static let bottomNotch: CGFloat = 0.0
|
||||
static let appSafeAreaTop: CGFloat = 0.0
|
||||
var modelName: String { "mac" }
|
||||
}
|
||||
|
||||
enum UIKeyboardType: Int {
|
||||
case `default` = 0
|
||||
case asciiCapable = 1
|
||||
case numbersAndPunctuation = 2
|
||||
case URL = 3
|
||||
case numberPad = 4
|
||||
case phonePad = 10
|
||||
case namePhonePad = 9
|
||||
case emailAddress = 7
|
||||
case decimalPad = 8
|
||||
case twitter = 12
|
||||
case webSearch = 13
|
||||
case asciiCapableNumberPad = 14
|
||||
}
|
||||
|
||||
extension View {
|
||||
@ViewBuilder
|
||||
func keyboardType(_ type: UIKeyboardType) -> some View {
|
||||
self
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
func appReadClipboardText() -> String? {
|
||||
#if canImport(UIKit)
|
||||
return UIPasteboard.general.string
|
||||
#elseif canImport(AppKit)
|
||||
return NSPasteboard.general.string(forType: .string)
|
||||
#else
|
||||
return nil
|
||||
#endif
|
||||
}
|
||||
|
||||
func appWriteClipboardText(_ value: String) {
|
||||
#if canImport(UIKit)
|
||||
UIPasteboard.general.string = value
|
||||
#elseif canImport(AppKit)
|
||||
NSPasteboard.general.clearContents()
|
||||
NSPasteboard.general.setString(value, forType: .string)
|
||||
#else
|
||||
_ = value
|
||||
#endif
|
||||
}
|
||||
|
||||
extension View {
|
||||
@ViewBuilder
|
||||
func appInlineNavigationTitle() -> some View {
|
||||
#if os(macOS)
|
||||
self
|
||||
#else
|
||||
self
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.modifier(AppRoundedBackButtonModifier())
|
||||
#endif
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
func appHiddenNavigationBar() -> some View {
|
||||
#if os(macOS)
|
||||
self
|
||||
#else
|
||||
self
|
||||
.toolbar(.hidden, for: .navigationBar)
|
||||
.toolbarBackground(.hidden, for: .navigationBar)
|
||||
#endif
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
func appTopBarTrailingToolbar<Content: View>(@ViewBuilder content: () -> Content) -> some View {
|
||||
#if os(macOS)
|
||||
self.toolbar {
|
||||
ToolbarItem {
|
||||
content()
|
||||
}
|
||||
}
|
||||
#else
|
||||
self.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
content()
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
@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)
|
||||
}
|
||||
}
|
||||
|
||||
#if !os(macOS)
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
47
Sources/PediFoods/Views/Auth/AuthFlowView.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
88
Sources/PediFoods/Views/Auth/AuthFormatters.swift
Normal 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."
|
||||
}
|
||||
89
Sources/PediFoods/Views/Auth/LegalViews.swift
Normal file
@@ -0,0 +1,89 @@
|
||||
import SwiftUI
|
||||
|
||||
struct TermsOfUseView: View {
|
||||
@Environment(\.colorScheme) var colorScheme
|
||||
@Environment(\.dismiss) var dismiss
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
screenHeader
|
||||
Text("Termos de Uso")
|
||||
.font(AppTypography.heading1)
|
||||
.foregroundStyle(AppColors.textPrimary)
|
||||
Text("Conteúdo dos termos de uso...")
|
||||
.font(AppTypography.body)
|
||||
.foregroundStyle(AppColors.textPrimary)
|
||||
}
|
||||
.padding(24)
|
||||
}
|
||||
.background((colorScheme == .dark ? Color.black : AppColors.backgroundLight).ignoresSafeArea())
|
||||
.appHiddenNavigationBar()
|
||||
.navigationBarBackButtonHidden(true)
|
||||
}
|
||||
|
||||
private var screenHeader: some View {
|
||||
ZStack {
|
||||
Text("Termos de Uso")
|
||||
.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 PrivacyPolicyView: View {
|
||||
@Environment(\.colorScheme) var colorScheme
|
||||
@Environment(\.dismiss) var dismiss
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
screenHeader
|
||||
Text("Política de Privacidade")
|
||||
.font(AppTypography.heading1)
|
||||
.foregroundStyle(AppColors.textPrimary)
|
||||
Text("Conteúdo da política de privacidade...")
|
||||
.font(AppTypography.body)
|
||||
.foregroundStyle(AppColors.textPrimary)
|
||||
}
|
||||
.padding(24)
|
||||
}
|
||||
.background((colorScheme == .dark ? Color.black : AppColors.backgroundLight).ignoresSafeArea())
|
||||
.appHiddenNavigationBar()
|
||||
.navigationBarBackButtonHidden(true)
|
||||
}
|
||||
|
||||
private var screenHeader: some View {
|
||||
ZStack {
|
||||
Text("Privacidade")
|
||||
.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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
146
Sources/PediFoods/Views/Auth/LoginEmailView.swift
Normal 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)
|
||||
)
|
||||
}
|
||||
}
|
||||
142
Sources/PediFoods/Views/Auth/LoginView.swift
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
390
Sources/PediFoods/Views/Auth/OtpView.swift
Normal file
@@ -0,0 +1,390 @@
|
||||
import SwiftUI
|
||||
#if canImport(UIKit)
|
||||
import UIKit
|
||||
#endif
|
||||
|
||||
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)
|
||||
}
|
||||
} 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.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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
168
Sources/PediFoods/Views/Auth/RegistrationView.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
42
Sources/PediFoods/Views/Launch/LaunchSplashView.swift
Normal 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()
|
||||
}
|
||||
}
|
||||
254
Sources/PediFoods/Views/Main/AddAddressFormView.swift
Normal 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
|
||||
}
|
||||
}
|
||||
461
Sources/PediFoods/Views/Main/AddCardFormView.swift
Normal 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))"
|
||||
}
|
||||
}
|
||||
203
Sources/PediFoods/Views/Main/AddressComponents.swift
Normal file
@@ -0,0 +1,203 @@
|
||||
import SwiftUI
|
||||
#if canImport(UIKit)
|
||||
import UIKit
|
||||
#endif
|
||||
|
||||
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() {
|
||||
#if canImport(UIKit)
|
||||
UIImpactFeedbackGenerator(style: .light).impactOccurred()
|
||||
#endif
|
||||
}
|
||||
|
||||
func triggerSelectionHaptic() {
|
||||
#if canImport(UIKit)
|
||||
UISelectionFeedbackGenerator().selectionChanged()
|
||||
#endif
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
446
Sources/PediFoods/Views/Main/AddressesView.swift
Normal file
@@ -0,0 +1,446 @@
|
||||
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 ?? ""
|
||||
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
|
||||
}
|
||||
}
|
||||
356
Sources/PediFoods/Views/Main/CartView.swift
Normal file
@@ -0,0 +1,356 @@
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
struct CartView: View {
|
||||
@Binding var appState: AppState
|
||||
@Binding var selectedTab: MainTab
|
||||
@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 {
|
||||
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()
|
||||
let addresses = profileResponse.result?.addressBook ?? []
|
||||
|
||||
if let selectedId = appState.address.selectedId, selectedId.isEmpty == false {
|
||||
selectedCustomerAddress = addresses.first(where: { $0.id == selectedId })
|
||||
} else {
|
||||
selectedCustomerAddress = nil
|
||||
}
|
||||
|
||||
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.selectedId = selected.id
|
||||
let label = (selected.label ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if label.isEmpty == false {
|
||||
appState.address.display = label
|
||||
}
|
||||
if let lat = selected.latLong?.first, let lng = selected.latLong?.dropFirst().first {
|
||||
appState.address.latitude = lat
|
||||
appState.address.longitude = lng
|
||||
}
|
||||
SessionStateStore.saveAddress(appState.address)
|
||||
}
|
||||
|
||||
let payloadLat = selectedCustomerAddress?.latLong?.first ?? appState.address.latitude
|
||||
let payloadLng = selectedCustomerAddress?.latLong?.dropFirst().first ?? appState.address.longitude
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
41
Sources/PediFoods/Views/Main/CheckoutTypes.swift
Normal file
@@ -0,0 +1,41 @@
|
||||
import Foundation
|
||||
|
||||
enum CheckoutDeliveryType: String {
|
||||
case delivery = "DELIVERY"
|
||||
case pickup = "PICKUP"
|
||||
}
|
||||
|
||||
enum CheckoutPaymentMethod: String {
|
||||
case pix = "PIX"
|
||||
case creditCard = "CREDIT_CARD"
|
||||
case debitCard = "DEBIT_CARD"
|
||||
case money = "MONEY"
|
||||
case voucher = "VOUCHER"
|
||||
|
||||
var label: String {
|
||||
switch self {
|
||||
case .pix: return "PIX"
|
||||
case .creditCard: return "Cartão de Crédito"
|
||||
case .debitCard: return "Cartão de Débito"
|
||||
case .money: return "Dinheiro"
|
||||
case .voucher: return "Vale Refeição/Alimentação"
|
||||
}
|
||||
}
|
||||
|
||||
var subtitle: String? {
|
||||
switch self {
|
||||
case .pix: return "Aprovação imediata"
|
||||
case .creditCard: return "No app: rápido e seguro"
|
||||
default: return nil
|
||||
}
|
||||
}
|
||||
|
||||
var iconName: String {
|
||||
switch self {
|
||||
case .pix: return "bolt.fill"
|
||||
case .creditCard, .debitCard: return "creditcard.fill"
|
||||
case .money: return "banknote.fill"
|
||||
case .voucher: return "ticket.fill"
|
||||
}
|
||||
}
|
||||
}
|
||||
432
Sources/PediFoods/Views/Main/CheckoutView+Logic.swift
Normal file
@@ -0,0 +1,432 @@
|
||||
import SwiftUI
|
||||
|
||||
extension CheckoutView {
|
||||
enum CheckoutPayloadValidationError: LocalizedError {
|
||||
case emptyCart
|
||||
case missingCustomerName
|
||||
case missingCustomerEmail
|
||||
case missingCustomerPhone
|
||||
case missingAddressStreet
|
||||
case missingAddressNumber
|
||||
case missingAddressNeighborhood
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .emptyCart: return "Carrinho vazio."
|
||||
case .missingCustomerName: return "Nome do cliente não informado."
|
||||
case .missingCustomerEmail: return "Email do cliente não informado."
|
||||
case .missingCustomerPhone: return "Telefone do cliente não informado."
|
||||
case .missingAddressStreet: return "Rua do endereço não informada."
|
||||
case .missingAddressNumber: return "Número do endereço não informado."
|
||||
case .missingAddressNeighborhood: return "Bairro do endereço não informado."
|
||||
}
|
||||
}
|
||||
}
|
||||
var checkoutAddressWatchKey: String {
|
||||
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 "\(selectedId)|\(display)|\(lat)|\(lng)"
|
||||
}
|
||||
|
||||
func formatCurrency(_ value: Double) -> String {
|
||||
String(format: "R$ %.2f", value).replacingOccurrences(of: ".", with: ",")
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func updateMinOrderSnackbar() {
|
||||
guard isBelowMinOrder else {
|
||||
SnackbarCenter.shared.dismissPersistent()
|
||||
return
|
||||
}
|
||||
let missing = formatCurrency(minOrderValue - totalValue)
|
||||
SnackbarCenter.shared.show(
|
||||
title: "Pedido mínimo de \(formatCurrency(minOrderValue)). Faltam \(missing) para finalizar.",
|
||||
style: .warning,
|
||||
icon: "exclamationmark.circle.fill",
|
||||
isPersistent: true
|
||||
)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func loadStoreInfoIfNeeded() async {
|
||||
guard let storeId = appState.cart.storeId, storeId.isEmpty == false else { return }
|
||||
do {
|
||||
let response = try await ApiService().storeInfo(storeId: storeId)
|
||||
if response.error {
|
||||
errorMessage = response.message ?? "Não foi possível carregar opções de checkout."
|
||||
return
|
||||
}
|
||||
storeInfo = response.result
|
||||
if showPaymentModeToggle == false {
|
||||
useInAppPayment = true
|
||||
}
|
||||
errorMessage = nil
|
||||
} catch {
|
||||
errorMessage = "Não foi possível carregar opções de checkout."
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func refreshSelectedCustomerAddress() async {
|
||||
do {
|
||||
let response = try await ApiService().profile()
|
||||
if let customer = response.result {
|
||||
appState.profile.id = customer.id
|
||||
appState.profile.name = customer.name
|
||||
appState.profile.email = customer.email
|
||||
if let phoneNumber = customer.phoneNumber, phoneNumber.isEmpty == false {
|
||||
appState.profile.phone = phoneNumber
|
||||
}
|
||||
appState.profile.profilePicture = customer.profilePicture ?? ""
|
||||
SessionStateStore.setActiveUserKey(
|
||||
SessionStateStore.makeUserKey(profileId: customer.id, email: customer.email)
|
||||
)
|
||||
}
|
||||
|
||||
let addresses = response.result?.addressBook ?? []
|
||||
if let selectedId = appState.address.selectedId, selectedId.isEmpty == false {
|
||||
selectedCustomerAddress = addresses.first(where: { $0.id == selectedId })
|
||||
} else {
|
||||
selectedCustomerAddress = nil
|
||||
}
|
||||
|
||||
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,
|
||||
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 {
|
||||
selectedCustomerAddress = addresses.first
|
||||
}
|
||||
|
||||
if let selected = selectedCustomerAddress {
|
||||
appState.address.selectedId = selected.id
|
||||
let label = (selected.label ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if label.isEmpty == false {
|
||||
appState.address.display = label
|
||||
}
|
||||
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 {
|
||||
selectedCustomerAddress = nil
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func validateDeliveryAddressIfNeeded() async {
|
||||
guard isDeliveryMode else {
|
||||
addressValidationBlocked = false
|
||||
addressValidationMessage = nil
|
||||
baseDeliveryFee = nil
|
||||
return
|
||||
}
|
||||
|
||||
guard let storeId = appState.cart.storeId, storeId.isEmpty == false else { return }
|
||||
|
||||
baseDeliveryFee = nil
|
||||
|
||||
let payload = ValidateDeliveryAddressPayload(
|
||||
address: ValidateDeliveryAddressDataPayload(
|
||||
street: selectedCustomerAddress?.address,
|
||||
number: selectedCustomerAddress?.number,
|
||||
neighborhood: selectedCustomerAddress?.neighborhood,
|
||||
city: selectedCustomerAddress?.city,
|
||||
state: selectedCustomerAddress?.state,
|
||||
zip: selectedCustomerAddress?.zipCode,
|
||||
lat: appState.address.latitude,
|
||||
lng: appState.address.longitude
|
||||
)
|
||||
)
|
||||
|
||||
isValidatingAddress = true
|
||||
defer { isValidatingAddress = false }
|
||||
|
||||
do {
|
||||
let response = try await ApiService().validateDeliveryAddress(storeId: storeId, payload: payload)
|
||||
if response.error {
|
||||
addressValidationBlocked = true
|
||||
addressValidationMessage = response.message ?? "Não foi possível validar o endereço de entrega."
|
||||
baseDeliveryFee = nil
|
||||
return
|
||||
}
|
||||
|
||||
let result = response.result
|
||||
let allowed = result?.deliveryAllowed ?? false
|
||||
addressValidationBlocked = allowed == false
|
||||
addressValidationMessage = result?.reasonMessage
|
||||
|
||||
if allowed {
|
||||
lastAcceptedAddressState = appState.address
|
||||
} else {
|
||||
showAddressNotServedAlert = true
|
||||
baseDeliveryFee = nil
|
||||
}
|
||||
|
||||
if allowed {
|
||||
if let fee = result?.deliveryFee {
|
||||
baseDeliveryFee = fee
|
||||
} else {
|
||||
addressValidationBlocked = true
|
||||
addressValidationMessage = "Não foi possível calcular a taxa de entrega para este endereço."
|
||||
baseDeliveryFee = nil
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
addressValidationBlocked = true
|
||||
addressValidationMessage = "Não foi possível validar o endereço de entrega."
|
||||
baseDeliveryFee = nil
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeSelectedOptions() {
|
||||
if availableDeliveryTypes.contains(deliveryType) == false,
|
||||
let first = availableDeliveryTypes.first {
|
||||
deliveryType = first
|
||||
}
|
||||
|
||||
if useInAppPayment {
|
||||
if availableInAppPaymentMethods.contains(paymentMethod) == false,
|
||||
let first = availableInAppPaymentMethods.first {
|
||||
paymentMethod = first
|
||||
}
|
||||
} else {
|
||||
if availableStoreMachineMethods.contains(paymentMethod) == false,
|
||||
let first = availableStoreMachineMethods.first {
|
||||
paymentMethod = first
|
||||
}
|
||||
}
|
||||
|
||||
if lastAcceptedAddressState == nil {
|
||||
lastAcceptedAddressState = appState.address
|
||||
}
|
||||
}
|
||||
|
||||
func restoreLastAcceptedAddress() {
|
||||
guard let snapshot = lastAcceptedAddressState else { return }
|
||||
isRestoringAddress = true
|
||||
appState.address = snapshot
|
||||
SessionStateStore.saveAddress(snapshot)
|
||||
Task { @MainActor in
|
||||
await refreshSelectedCustomerAddress()
|
||||
addressValidationBlocked = false
|
||||
addressValidationMessage = nil
|
||||
isRestoringAddress = false
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func handleConfirmPaymentTap() async {
|
||||
guard canConfirmPayment else { return }
|
||||
|
||||
guard let storeId = appState.cart.storeId, storeId.isEmpty == false else {
|
||||
SnackbarCenter.shared.show(title: "Loja do pedido não encontrada.", style: .error, icon: "xmark.octagon.fill", duration: 2.0)
|
||||
return
|
||||
}
|
||||
|
||||
await refreshSelectedCustomerAddress()
|
||||
|
||||
let effectivePaymentMethod = paymentMethod
|
||||
if useInAppPayment && availableInAppPaymentMethods.contains(effectivePaymentMethod) == false {
|
||||
SnackbarCenter.shared.show(title: "Método de pagamento não suportado no app.", style: .warning, icon: "exclamationmark.triangle.fill", duration: 2.0)
|
||||
return
|
||||
}
|
||||
|
||||
// Crédito pelo app → abre seleção de cartão antes de criar pedido
|
||||
if useInAppPayment && effectivePaymentMethod == .creditCard {
|
||||
showCardSelectionSheet = true
|
||||
return
|
||||
}
|
||||
|
||||
let payloadBuildResult = buildCreateOrderPayload(paymentMethod: effectivePaymentMethod, savedCardId: nil)
|
||||
guard case .success(let payload) = payloadBuildResult else {
|
||||
let message: String
|
||||
if case .failure(let reason) = payloadBuildResult {
|
||||
message = reason.localizedDescription
|
||||
} else {
|
||||
message = "Dados do pedido incompletos."
|
||||
}
|
||||
SnackbarCenter.shared.show(title: message, style: .error, icon: "xmark.octagon.fill", duration: 2.0)
|
||||
return
|
||||
}
|
||||
|
||||
isSubmittingOrder = true
|
||||
defer { isSubmittingOrder = false }
|
||||
|
||||
do {
|
||||
let response = try await ApiService().createOrder(storeId: storeId, payload: payload)
|
||||
let isInAppMethod = availableInAppPaymentMethods.contains(effectivePaymentMethod)
|
||||
if useInAppPayment == false || isInAppMethod == false {
|
||||
if response.error == false, let result = response.result {
|
||||
let orderId = result.id ?? UUID().uuidString
|
||||
appState.cart.clear()
|
||||
SessionStateStore.clearPendingCartOrder()
|
||||
orderTrackingContext = OrderTrackingContext(orderId: orderId, shortId: result.shortId)
|
||||
} else {
|
||||
SnackbarCenter.shared.show(title: response.message ?? "Não foi possível criar o pedido.", style: .error, icon: "xmark.octagon.fill", duration: 3.0)
|
||||
}
|
||||
return
|
||||
}
|
||||
handleOrderResponse(response, effectivePaymentMethod: effectivePaymentMethod)
|
||||
} catch {
|
||||
SnackbarCenter.shared.show(title: "Não foi possível criar o pedido.", style: .error, icon: "xmark.octagon.fill", duration: 3.0)
|
||||
}
|
||||
}
|
||||
|
||||
func buildCreateOrderPayload(
|
||||
paymentMethod: CheckoutPaymentMethod,
|
||||
savedCardId: String? = nil,
|
||||
creditCard: CreditCardOrderPayload? = nil,
|
||||
creditCardHolderInfo: SaveCardHolderInfoPayload? = nil,
|
||||
clientCpfCnpj: String? = nil
|
||||
) -> Result<CreateOrderPayload, CheckoutPayloadValidationError> {
|
||||
guard appState.cart.items.isEmpty == false else { return .failure(.emptyCart) }
|
||||
|
||||
let profileName = appState.profile.name.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let profileEmail = appState.profile.email.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let profilePhone = appState.profile.phone.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard profileName.isEmpty == false else { return .failure(.missingCustomerName) }
|
||||
guard profileEmail.isEmpty == false else { return .failure(.missingCustomerEmail) }
|
||||
guard profilePhone.isEmpty == false else { return .failure(.missingCustomerPhone) }
|
||||
|
||||
let addressPayload: CreateOrderAddressPayload?
|
||||
if isDeliveryMode {
|
||||
let street = selectedCustomerAddress?.address?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
let number = selectedCustomerAddress?.number?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
let neighborhood = selectedCustomerAddress?.neighborhood?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
guard street.isEmpty == false else { return .failure(.missingAddressStreet) }
|
||||
guard number.isEmpty == false else { return .failure(.missingAddressNumber) }
|
||||
guard neighborhood.isEmpty == false else { return .failure(.missingAddressNeighborhood) }
|
||||
addressPayload = CreateOrderAddressPayload(
|
||||
street: street,
|
||||
number: number,
|
||||
neighborhood: neighborhood,
|
||||
city: selectedCustomerAddress?.city,
|
||||
state: selectedCustomerAddress?.state,
|
||||
zip: selectedCustomerAddress?.zipCode,
|
||||
complement: selectedCustomerAddress?.complement
|
||||
)
|
||||
} else {
|
||||
addressPayload = nil
|
||||
}
|
||||
|
||||
return .success(
|
||||
CreateOrderPayload(
|
||||
customer: CreateOrderCustomerPayload(
|
||||
name: profileName,
|
||||
phone: profilePhone,
|
||||
email: profileEmail,
|
||||
asaasId: nil
|
||||
),
|
||||
items: appState.cart.toOrderItemsPayload(),
|
||||
total: totalValue,
|
||||
paymentMethod: paymentMethod.rawValue,
|
||||
deliveryType: deliveryType.rawValue,
|
||||
address: addressPayload,
|
||||
savedCardId: savedCardId,
|
||||
clientCpfCnpj: clientCpfCnpj,
|
||||
creditCard: creditCard,
|
||||
creditCardHolderInfo: creditCardHolderInfo
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func confirmOrderWithSavedCard(cardId: String, storeId: String) async {
|
||||
let payloadResult = buildCreateOrderPayload(paymentMethod: .creditCard, savedCardId: cardId)
|
||||
guard case .success(let payload) = payloadResult else { return }
|
||||
|
||||
isSubmittingOrder = true
|
||||
defer { isSubmittingOrder = false }
|
||||
|
||||
do {
|
||||
let response = try await ApiService().createOrder(storeId: storeId, payload: payload)
|
||||
handleOrderResponse(response, effectivePaymentMethod: .creditCard)
|
||||
} catch {
|
||||
SnackbarCenter.shared.show(title: "Não foi possível criar o pedido.", style: .error, icon: "xmark.octagon.fill", duration: 3.0)
|
||||
}
|
||||
}
|
||||
|
||||
func handleOrderResponse(_ response: ApiEnvelope<CreateOrderResult>, effectivePaymentMethod: CheckoutPaymentMethod) {
|
||||
if response.error {
|
||||
SnackbarCenter.shared.show(
|
||||
title: response.message ?? "Não foi possível criar o pedido.",
|
||||
style: .error,
|
||||
icon: "xmark.octagon.fill",
|
||||
duration: 3.0
|
||||
)
|
||||
return
|
||||
}
|
||||
guard let result = response.result else {
|
||||
SnackbarCenter.shared.show(title: "Resposta de pagamento inválida.", style: .error, icon: "xmark.octagon.fill", duration: 3.0)
|
||||
return
|
||||
}
|
||||
|
||||
let orderSnapshot = result.asPublicOrderResult()
|
||||
SessionStateStore.saveTrackedOrder(orderSnapshot)
|
||||
let orderId = result.id ?? UUID().uuidString
|
||||
|
||||
if orderSnapshot.isPaymentConfirmed {
|
||||
appState.cart.clear()
|
||||
SessionStateStore.clearPendingCartOrder()
|
||||
orderTrackingContext = OrderTrackingContext(orderId: orderId, shortId: result.shortId)
|
||||
return
|
||||
}
|
||||
|
||||
SessionStateStore.savePendingCartOrderId(orderId)
|
||||
|
||||
let pixFromPayment = result.payment?.pix
|
||||
let pixFromPayload = result.paymentPayload
|
||||
let copyPaste = (pixFromPayment?.copyPaste?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false)
|
||||
? pixFromPayment?.copyPaste : pixFromPayload?.copyPaste
|
||||
let qrCodeImage = (pixFromPayment?.qrCodeImage?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false)
|
||||
? pixFromPayment?.qrCodeImage : pixFromPayload?.qrCodeImage
|
||||
let expirationDate = (pixFromPayment?.expirationDate?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false)
|
||||
? pixFromPayment?.expirationDate : pixFromPayload?.expirationDate
|
||||
|
||||
if let copyPaste, copyPaste.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false {
|
||||
let itemsData = (try? JSONEncoder().encode(appState.cart.toOrderItemsPayload())).flatMap { String(data: $0, encoding: .utf8) } ?? "[]"
|
||||
let storeId = appState.cart.storeId ?? ""
|
||||
pixPaymentContext = PixPaymentContext(
|
||||
id: orderId,
|
||||
orderId: orderId,
|
||||
shortId: result.shortId,
|
||||
storeId: storeId,
|
||||
copyPaste: copyPaste,
|
||||
qrCodeImageBase64: qrCodeImage,
|
||||
expirationDate: expirationDate,
|
||||
total: totalValue,
|
||||
profileName: appState.profile.name,
|
||||
profileEmail: appState.profile.email,
|
||||
profilePhone: appState.profile.phone,
|
||||
addressZip: selectedCustomerAddress?.zipCode,
|
||||
addressNumber: selectedCustomerAddress?.number,
|
||||
deliveryType: deliveryType.rawValue,
|
||||
itemsJSON: itemsData
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
orderTrackingContext = OrderTrackingContext(orderId: orderId, shortId: result.shortId)
|
||||
}
|
||||
}
|
||||
1889
Sources/PediFoods/Views/Main/CheckoutView.swift
Normal file
235
Sources/PediFoods/Views/Main/FiltersModalView.swift
Normal file
@@ -0,0 +1,235 @@
|
||||
import SwiftUI
|
||||
|
||||
struct FiltersModalView: View {
|
||||
@Binding var appState: AppState
|
||||
@Environment(\.dismiss) var dismiss
|
||||
@State var draftFilters: HomeFiltersState
|
||||
|
||||
init(appState: Binding<AppState>) {
|
||||
_appState = appState
|
||||
_draftFilters = State(initialValue: appState.wrappedValue.homeFilters)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
header
|
||||
|
||||
ScrollView(showsIndicators: false) {
|
||||
VStack(alignment: .leading, spacing: 28) {
|
||||
sortSection
|
||||
categoriesSection
|
||||
priceSection
|
||||
distanceSection
|
||||
}
|
||||
.padding(.horizontal, 24)
|
||||
.padding(.top, 22)
|
||||
.padding(.bottom, 120)
|
||||
}
|
||||
|
||||
applyButton
|
||||
.padding(.horizontal, 24)
|
||||
.padding(.vertical, 18)
|
||||
.background(AppColors.backgroundLight)
|
||||
}
|
||||
.background(AppColors.backgroundLight.ignoresSafeArea())
|
||||
}
|
||||
|
||||
var header: some View {
|
||||
HStack {
|
||||
Button {
|
||||
dismiss()
|
||||
} label: {
|
||||
Image(systemName: "xmark")
|
||||
.font(.system(size: 20, weight: .medium))
|
||||
.foregroundStyle(AppColors.textPrimary)
|
||||
.frame(width: 36, height: 36)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
Spacer()
|
||||
|
||||
Text("Filtros")
|
||||
.font(AppTypography.heading2)
|
||||
.foregroundStyle(AppColors.textPrimary)
|
||||
|
||||
Spacer()
|
||||
|
||||
Button("Limpar") {
|
||||
draftFilters.reset()
|
||||
draftFilters.availableCategories = appState.homeFilters.availableCategories
|
||||
}
|
||||
.font(AppTypography.heading3)
|
||||
.foregroundStyle(AppColors.secondary)
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
.padding(.horizontal, 24)
|
||||
.padding(.top, 14)
|
||||
.padding(.bottom, 12)
|
||||
.overlay(alignment: .bottom) {
|
||||
Divider().overlay(Color.black.opacity(0.08))
|
||||
}
|
||||
}
|
||||
|
||||
var sortSection: some View {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
Text("Ordenar por")
|
||||
.font(AppTypography.heading1)
|
||||
.foregroundStyle(AppColors.textPrimary)
|
||||
|
||||
VStack(spacing: 10) {
|
||||
ForEach(HomeSortOption.allCases) { option in
|
||||
Button {
|
||||
draftFilters.sortOption = option
|
||||
} label: {
|
||||
HStack(spacing: 14) {
|
||||
Circle()
|
||||
.fill(option == draftFilters.sortOption ? AppColors.tertiary : AppColors.surface)
|
||||
.frame(width: 44, height: 44)
|
||||
.overlay(
|
||||
Image(systemName: option.icon)
|
||||
.font(.system(size: 17, weight: .semibold))
|
||||
.foregroundStyle(option == draftFilters.sortOption ? AppColors.textPrimary : AppColors.textMuted)
|
||||
)
|
||||
|
||||
Text(option.title)
|
||||
.font(AppTypography.heading2)
|
||||
.foregroundStyle(AppColors.textPrimary)
|
||||
|
||||
Spacer()
|
||||
|
||||
Circle()
|
||||
.stroke(option == draftFilters.sortOption ? Color.black : Color.black.opacity(0.2), lineWidth: 2)
|
||||
.frame(width: 28, height: 28)
|
||||
.overlay(
|
||||
Circle()
|
||||
.fill(option == draftFilters.sortOption ? Color.black : Color.clear)
|
||||
.frame(width: 14, height: 14)
|
||||
)
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.frame(height: 88)
|
||||
.background(Color.black.opacity(0.03))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 20, style: .continuous))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var categoriesSection: some View {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
Divider().overlay(Color.black.opacity(0.08))
|
||||
|
||||
HStack(alignment: .center) {
|
||||
Text("Categorias")
|
||||
.font(AppTypography.heading1)
|
||||
.foregroundStyle(AppColors.textPrimary)
|
||||
Spacer()
|
||||
Text("Ver todas")
|
||||
.font(AppTypography.heading3)
|
||||
.foregroundStyle(AppColors.textMuted)
|
||||
}
|
||||
|
||||
LazyVGrid(columns: [GridItem(.adaptive(minimum: 120), spacing: 10)], alignment: .leading, spacing: 10) {
|
||||
ForEach(draftFilters.availableCategories, id: \.self) { category in
|
||||
let isSelected = draftFilters.selectedCategories.contains(category)
|
||||
Button(category) {
|
||||
if isSelected {
|
||||
draftFilters.selectedCategories.remove(category)
|
||||
} else {
|
||||
draftFilters.selectedCategories.insert(category)
|
||||
}
|
||||
}
|
||||
.font(AppTypography.heading2)
|
||||
.foregroundStyle(AppColors.textPrimary)
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.vertical, 12)
|
||||
.background(isSelected ? AppColors.tertiary.opacity(0.7) : AppColors.surface)
|
||||
.overlay(
|
||||
Capsule().stroke(isSelected ? Color.clear : Color.black.opacity(0.12), lineWidth: 1)
|
||||
)
|
||||
.clipShape(Capsule())
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var priceSection: some View {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
Divider().overlay(Color.black.opacity(0.08))
|
||||
|
||||
Text("Preço")
|
||||
.font(AppTypography.heading1)
|
||||
.foregroundStyle(AppColors.textPrimary)
|
||||
|
||||
HStack(spacing: 12) {
|
||||
ForEach(HomePriceTier.allCases) { tier in
|
||||
let isSelected = draftFilters.selectedPriceTier == tier
|
||||
Button(tier.rawValue) {
|
||||
draftFilters.selectedPriceTier = isSelected ? nil : tier
|
||||
}
|
||||
.font(AppTypography.heading2)
|
||||
.foregroundStyle(AppColors.textPrimary)
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 68)
|
||||
.background(isSelected ? AppColors.tertiary.opacity(0.7) : AppColors.surface)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 24, style: .continuous)
|
||||
.stroke(isSelected ? Color.black : Color.black.opacity(0.12), lineWidth: isSelected ? 2 : 1)
|
||||
)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 24, style: .continuous))
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var distanceSection: some View {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
Divider().overlay(Color.black.opacity(0.08))
|
||||
|
||||
HStack {
|
||||
Text("Distância")
|
||||
.font(AppTypography.heading1)
|
||||
.foregroundStyle(AppColors.textPrimary)
|
||||
Spacer()
|
||||
Text("Até \(Int(draftFilters.maxDistanceKm))km")
|
||||
.font(AppTypography.heading2)
|
||||
.foregroundStyle(AppColors.textPrimary)
|
||||
}
|
||||
|
||||
Slider(value: $draftFilters.maxDistanceKm, in: 1...10, step: 1)
|
||||
.tint(AppColors.tertiary)
|
||||
|
||||
HStack {
|
||||
Text("1km")
|
||||
Spacer()
|
||||
Text("10km")
|
||||
}
|
||||
.font(AppTypography.heading3)
|
||||
.foregroundStyle(AppColors.textMuted)
|
||||
}
|
||||
}
|
||||
|
||||
var applyButton: some View {
|
||||
Button {
|
||||
appState.homeFilters.sortOption = draftFilters.sortOption
|
||||
appState.homeFilters.selectedCategories = draftFilters.selectedCategories
|
||||
appState.homeFilters.selectedPriceTier = draftFilters.selectedPriceTier
|
||||
appState.homeFilters.maxDistanceKm = draftFilters.maxDistanceKm
|
||||
appState.homeFilters.availableCategories = draftFilters.availableCategories
|
||||
dismiss()
|
||||
} label: {
|
||||
Text("Aplicar Filtros")
|
||||
.font(AppTypography.heading2)
|
||||
.foregroundStyle(AppColors.textPrimary)
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 62)
|
||||
.background(AppColors.tertiary.opacity(0.7))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 20, style: .continuous))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
33
Sources/PediFoods/Views/Main/HomeScrollOffsetObserver.swift
Normal file
@@ -0,0 +1,33 @@
|
||||
import SwiftUI
|
||||
|
||||
enum HomeScrollCoordinateSpace {
|
||||
static let name = "home-scroll"
|
||||
}
|
||||
|
||||
struct HomeScrollOffsetPreferenceKey: PreferenceKey {
|
||||
static let defaultValue: CGFloat = 0
|
||||
|
||||
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
|
||||
value = nextValue()
|
||||
}
|
||||
}
|
||||
|
||||
struct ScrollOffsetObserver: View {
|
||||
let onOffsetChange: (CGFloat) -> Void
|
||||
|
||||
var body: some View {
|
||||
Color.clear
|
||||
.frame(height: 0)
|
||||
.background(
|
||||
GeometryReader { geometry in
|
||||
Color.clear.preference(
|
||||
key: HomeScrollOffsetPreferenceKey.self,
|
||||
value: geometry.frame(in: .named(HomeScrollCoordinateSpace.name)).minY
|
||||
)
|
||||
}
|
||||
)
|
||||
.onPreferenceChange(HomeScrollOffsetPreferenceKey.self) { minY in
|
||||
onOffsetChange(-minY)
|
||||
}
|
||||
}
|
||||
}
|
||||
138
Sources/PediFoods/Views/Main/HomeView+Data.swift
Normal file
@@ -0,0 +1,138 @@
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
extension HomeView {
|
||||
func buildCategories(from stores: [StoreSummary]) -> [CategoryModel] {
|
||||
var unique: [CategoryModel] = [
|
||||
.init(id: "all", title: "Todas", systemIcon: "line.3.horizontal.decrease.circle", emojiIcon: nil)
|
||||
]
|
||||
var seen = Set<String>()
|
||||
|
||||
for store in stores {
|
||||
let raw = (store.category ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if raw.isEmpty { continue }
|
||||
let dedupe = raw.lowercased()
|
||||
if seen.contains(dedupe) { continue }
|
||||
seen.insert(dedupe)
|
||||
unique.append(.init(id: raw, title: raw, systemIcon: categoryIcon(for: raw), emojiIcon: nil))
|
||||
}
|
||||
return unique
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func loadHomeCategories(withFallbackStores stores: [StoreSummary], forceRefresh: Bool = false) async {
|
||||
let cacheKey = "public-categories"
|
||||
if forceRefresh == false,
|
||||
let cached: [CategoryModel] = AppContentCache.shared.value(for: cacheKey, as: [CategoryModel].self) {
|
||||
categories = cached
|
||||
return
|
||||
}
|
||||
|
||||
do {
|
||||
let response = try await ApiService().listPublicCategories(forceRefresh: forceRefresh)
|
||||
if response.error == false, let remote = response.result, remote.isEmpty == false {
|
||||
let mapped = mapPublicCategories(remote)
|
||||
categories = mapped
|
||||
AppContentCache.shared.set(mapped, for: cacheKey, ttl: AppCacheTTL.twoHours)
|
||||
return
|
||||
}
|
||||
} catch {
|
||||
// Fallback handled below.
|
||||
}
|
||||
|
||||
let fallback = buildCategories(from: stores)
|
||||
categories = fallback
|
||||
AppContentCache.shared.set(fallback, for: cacheKey, ttl: AppCacheTTL.twoHours)
|
||||
}
|
||||
|
||||
func mapPublicCategories(_ remote: [PublicCategory]) -> [CategoryModel] {
|
||||
var mapped: [CategoryModel] = []
|
||||
var seen = Set<String>()
|
||||
|
||||
for item in remote {
|
||||
let id = item.id.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let title = item.name.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if id.isEmpty || title.isEmpty { continue }
|
||||
if seen.contains(id.lowercased()) { continue }
|
||||
seen.insert(id.lowercased())
|
||||
mapped.append(
|
||||
.init(
|
||||
id: id,
|
||||
title: title,
|
||||
systemIcon: id.lowercased() == "all" ? "line.3.horizontal.decrease.circle" : nil,
|
||||
emojiIcon: item.icon
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
if mapped.contains(where: { $0.id.lowercased() == "all" }) == false {
|
||||
mapped.insert(.init(id: "all", title: "Todas", systemIcon: "line.3.horizontal.decrease.circle", emojiIcon: nil), at: 0)
|
||||
} else {
|
||||
mapped.sort { lhs, rhs in
|
||||
if lhs.id.lowercased() == "all" { return true }
|
||||
if rhs.id.lowercased() == "all" { return false }
|
||||
return lhs.title < rhs.title
|
||||
}
|
||||
}
|
||||
|
||||
return mapped
|
||||
}
|
||||
|
||||
func categoryIcon(for category: String) -> String {
|
||||
let value = category.folding(options: [.diacriticInsensitive, .caseInsensitive], locale: .current).lowercased()
|
||||
if value.contains("pizza") { return "takeoutbag.and.cup.and.straw" }
|
||||
if value.contains("lanche") || value.contains("hamburg") || value.contains("burger") { return "fork.knife" }
|
||||
if value.contains("cafe") || value.contains("breakfast") { return "sun.max" }
|
||||
if value.contains("doce") || value.contains("sobremesa") || value.contains("dessert") { return "cup.and.saucer" }
|
||||
return "storefront"
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func resolveCoordinates(forceRefresh: Bool) async -> (Double, Double)? {
|
||||
guard hasConfiguredAddress() else {
|
||||
return nil
|
||||
}
|
||||
|
||||
// If user selected/saved an address, always trust its coordinates.
|
||||
// This avoids overriding the chosen city with current device GPS.
|
||||
if let lat = appState.address.latitude, let lng = appState.address.longitude {
|
||||
return (lat, lng)
|
||||
}
|
||||
|
||||
if !forceRefresh, let cached = LocationService.shared.cachedLocation() {
|
||||
appState.address.latitude = cached.0
|
||||
appState.address.longitude = cached.1
|
||||
return cached
|
||||
}
|
||||
|
||||
// Fallback to device location only when no address coordinates are available.
|
||||
let deviceCoordinate = await LocationService.shared.requestLocationAsync(timeoutSeconds: 3)
|
||||
if let deviceCoordinate {
|
||||
appState.address.latitude = deviceCoordinate.0
|
||||
appState.address.longitude = deviceCoordinate.1
|
||||
}
|
||||
return deviceCoordinate
|
||||
}
|
||||
|
||||
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"
|
||||
}
|
||||
|
||||
func storesUserMessage(_ error: Error) -> String {
|
||||
if let service = error as? ApiServiceError {
|
||||
return service.errorDescription ?? "Não foi possível carregar os estabelecimentos."
|
||||
}
|
||||
if let network = error as? NetworkError {
|
||||
return network.errorDescription ?? "Não foi possível carregar os estabelecimentos."
|
||||
}
|
||||
return "Não foi possível carregar os estabelecimentos."
|
||||
}
|
||||
}
|
||||
42
Sources/PediFoods/Views/Main/HomeView+Favorites.swift
Normal file
@@ -0,0 +1,42 @@
|
||||
import Foundation
|
||||
|
||||
extension HomeView {
|
||||
@MainActor
|
||||
func toggleFavoriteStore(storeId: String, storeName: String) async {
|
||||
guard favoriteRequestStoreIds.contains(storeId) == false else { return }
|
||||
guard appState.session.isAuthenticated else {
|
||||
SnackbarCenter.shared.show(title: "Faça login para favoritar lojas.", style: .warning, icon: "person.crop.circle.badge.exclamationmark", duration: 2.5)
|
||||
return
|
||||
}
|
||||
|
||||
let isFavorite = appState.favorites.storeIds.contains(storeId)
|
||||
favoriteRequestStoreIds.insert(storeId)
|
||||
defer { favoriteRequestStoreIds.remove(storeId) }
|
||||
|
||||
do {
|
||||
let response = try await ApiService().setStoreFavorite(storeId: storeId, isFavorite: isFavorite == false)
|
||||
guard response.error == false, let result = response.result else {
|
||||
let message = response.message ?? "Não foi possível atualizar seus favoritos."
|
||||
SnackbarCenter.shared.show(title: message, style: .error, icon: "xmark.octagon.fill", duration: 3.5)
|
||||
return
|
||||
}
|
||||
|
||||
appState.favorites.storeIds = Set(result.favorites)
|
||||
let successTitle = isFavorite
|
||||
? "\(storeName) removida dos favoritos."
|
||||
: "\(storeName) adicionada aos favoritos."
|
||||
let successIcon = isFavorite ? "heart.slash.fill" : "heart.fill"
|
||||
SnackbarCenter.shared.show(title: successTitle, style: .success, icon: successIcon, duration: 2.2)
|
||||
} catch {
|
||||
let message: String
|
||||
if let networkError = error as? NetworkError {
|
||||
message = networkError.errorDescription ?? "Não foi possível atualizar seus favoritos."
|
||||
} else if let serviceError = error as? ApiServiceError {
|
||||
message = serviceError.errorDescription ?? "Não foi possível atualizar seus favoritos."
|
||||
} else {
|
||||
message = "Não foi possível atualizar seus favoritos."
|
||||
}
|
||||
SnackbarCenter.shared.show(title: message, style: .error, icon: "xmark.octagon.fill", duration: 3.5)
|
||||
}
|
||||
}
|
||||
}
|
||||
217
Sources/PediFoods/Views/Main/HomeView+Filtering.swift
Normal file
@@ -0,0 +1,217 @@
|
||||
import Foundation
|
||||
|
||||
extension HomeView {
|
||||
var filteredStores: [StoreSummary] {
|
||||
let normalizedQuery = normalizeSearch(searchText)
|
||||
var list = stores
|
||||
|
||||
if appState.homeFilters.selectedCategories.isEmpty == false {
|
||||
let allowed = Set(appState.homeFilters.selectedCategories.map(normalizeSearch))
|
||||
list = list.filter { store in
|
||||
let category = normalizeSearch(store.category ?? "")
|
||||
return allowed.contains(category)
|
||||
}
|
||||
}
|
||||
|
||||
if let tier = appState.homeFilters.selectedPriceTier {
|
||||
list = list.filter { store in
|
||||
guard let fee = store.deliveryFee else { return false }
|
||||
return matchesPriceTier(fee: fee, tier: tier)
|
||||
}
|
||||
}
|
||||
|
||||
let maxDistance = appState.homeFilters.maxDistanceKm
|
||||
list = list.filter { store in
|
||||
guard let distance = store.distance else { return true }
|
||||
return distance <= maxDistance
|
||||
}
|
||||
|
||||
if normalizedQuery.isEmpty == false {
|
||||
list = list.filter { store in
|
||||
matchesSearch(store: store, query: normalizedQuery)
|
||||
}
|
||||
}
|
||||
|
||||
return sortStores(list, query: normalizedQuery)
|
||||
}
|
||||
|
||||
var featuredStoresCards: [FeaturedStoreCardModel] {
|
||||
Array(filteredStores.prefix(5)).map(mapStoreToCard)
|
||||
}
|
||||
|
||||
var filteredStoreCards: [FeaturedStoreCardModel] {
|
||||
let featuredIds = Set(filteredStores.prefix(5).map(\.id))
|
||||
let remaining = filteredStores.filter { featuredIds.contains($0.id) == false }
|
||||
return Array(remaining.prefix(20)).map(mapStoreToCard)
|
||||
}
|
||||
|
||||
var emptyResultMessage: String {
|
||||
if normalizeSearch(searchText).isEmpty == false {
|
||||
return "Nenhum resultado para \"\(searchText.trimmingCharacters(in: .whitespacesAndNewlines))\"."
|
||||
}
|
||||
return "Nenhum estabelecimento encontrado com os filtros selecionados."
|
||||
}
|
||||
|
||||
func mapStoreToCard(_ store: StoreSummary) -> FeaturedStoreCardModel {
|
||||
let coverURL = resolveStoreMediaURL(store.cover)
|
||||
let logoURL = resolveStoreMediaURL(store.logo)
|
||||
return FeaturedStoreCardModel(
|
||||
id: store.id,
|
||||
name: store.name,
|
||||
rating: store.rating ?? 0,
|
||||
reviews: String(store.positiveReviews ?? store.reviewsCount ?? 0),
|
||||
distance: formatDistance(store.distance),
|
||||
deliveryFee: store.deliveryFee,
|
||||
category: store.category ?? "Loja",
|
||||
promoText: nil,
|
||||
isFavorite: appState.favorites.storeIds.contains(store.id),
|
||||
iconName: "storefront",
|
||||
imageURL: logoURL ?? coverURL,
|
||||
logoURL: logoURL,
|
||||
coverURL: coverURL,
|
||||
isOpen: store.isOpen ?? true,
|
||||
statusLabel: store.statusLabel
|
||||
)
|
||||
}
|
||||
|
||||
func resolveStoreMediaURL(_ raw: String?) -> String? {
|
||||
ImageSourceResolver.resolve(raw)
|
||||
}
|
||||
|
||||
var profilePictureURL: String? {
|
||||
resolveStoreMediaURL(appState.profile.profilePicture)
|
||||
}
|
||||
|
||||
func formatDistance(_ distance: Double?) -> String {
|
||||
guard let distance else { return "Distância indisponível" }
|
||||
if distance >= 1 {
|
||||
return String(format: "%.1f km", distance)
|
||||
}
|
||||
return "\(Int(distance * 1000)) m"
|
||||
}
|
||||
|
||||
func scheduleSearchIndexUpdate() {
|
||||
searchDebounceToken += 1
|
||||
let token = searchDebounceToken
|
||||
Task {
|
||||
try? await Task.sleep(nanoseconds: 220_000_000)
|
||||
guard token == searchDebounceToken else { return }
|
||||
await loadProductIndexForSearchIfNeeded()
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func loadProductIndexForSearchIfNeeded() async {
|
||||
let query = normalizeSearch(searchText)
|
||||
guard query.isEmpty == false else { return }
|
||||
|
||||
let candidates = filteredStores
|
||||
.filter { productSearchIndexByStoreId[$0.id] == nil }
|
||||
.prefix(10)
|
||||
|
||||
guard candidates.isEmpty == false else { return }
|
||||
|
||||
await withTaskGroup(of: (String, [String]?).self) { group in
|
||||
for store in candidates {
|
||||
group.addTask {
|
||||
do {
|
||||
let response = try await ApiService().storeCatalog(storeId: store.id)
|
||||
let products = response.result?.flatMap(\.products) ?? []
|
||||
let names = products.map(\.name)
|
||||
return (store.id, names)
|
||||
} catch {
|
||||
return (store.id, nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for await result in group {
|
||||
let names = result.1 ?? []
|
||||
productSearchIndexByStoreId[result.0] = names
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeSearch(_ value: String) -> String {
|
||||
value
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
.folding(options: [.diacriticInsensitive, .caseInsensitive], locale: .current)
|
||||
.lowercased()
|
||||
}
|
||||
|
||||
func matchesSearch(store: StoreSummary, query: String) -> Bool {
|
||||
let storeName = normalizeSearch(store.name)
|
||||
if storeName.contains(query) {
|
||||
return true
|
||||
}
|
||||
|
||||
let category = normalizeSearch(store.category ?? "")
|
||||
if category.contains(query) {
|
||||
return true
|
||||
}
|
||||
|
||||
let products = productSearchIndexByStoreId[store.id] ?? []
|
||||
return products.contains { normalizeSearch($0).contains(query) }
|
||||
}
|
||||
|
||||
func sortStores(_ list: [StoreSummary], query: String) -> [StoreSummary] {
|
||||
switch appState.homeFilters.sortOption {
|
||||
case .relevance:
|
||||
return list.sorted { lhs, rhs in
|
||||
let lhsScore = relevanceScore(for: lhs, query: query)
|
||||
let rhsScore = relevanceScore(for: rhs, query: query)
|
||||
if lhsScore != rhsScore {
|
||||
return lhsScore > rhsScore
|
||||
}
|
||||
return (lhs.distance ?? .greatestFiniteMagnitude) < (rhs.distance ?? .greatestFiniteMagnitude)
|
||||
}
|
||||
case .rating:
|
||||
return list.sorted { ($0.rating ?? 0) > ($1.rating ?? 0) }
|
||||
case .deliveryTime:
|
||||
return list.sorted { estimatedDeliveryMinutes($0.deliveryTime) < estimatedDeliveryMinutes($1.deliveryTime) }
|
||||
case .price:
|
||||
return list.sorted { ($0.deliveryFee ?? .greatestFiniteMagnitude) < ($1.deliveryFee ?? .greatestFiniteMagnitude) }
|
||||
}
|
||||
}
|
||||
|
||||
func relevanceScore(for store: StoreSummary, query: String) -> Double {
|
||||
guard query.isEmpty == false else {
|
||||
let positive = Double(store.positiveReviews ?? store.reviewsCount ?? 0)
|
||||
return positive + (store.rating ?? 0) * 10
|
||||
}
|
||||
|
||||
let name = normalizeSearch(store.name)
|
||||
let category = normalizeSearch(store.category ?? "")
|
||||
let products = productSearchIndexByStoreId[store.id] ?? []
|
||||
|
||||
var score = 0.0
|
||||
if name.hasPrefix(query) { score += 200 }
|
||||
if name.contains(query) { score += 120 }
|
||||
if category.contains(query) { score += 70 }
|
||||
if products.contains(where: { normalizeSearch($0).contains(query) }) { score += 90 }
|
||||
score += (store.rating ?? 0) * 10
|
||||
score += Double(store.positiveReviews ?? store.reviewsCount ?? 0) * 0.02
|
||||
return score
|
||||
}
|
||||
|
||||
func estimatedDeliveryMinutes(_ value: String?) -> Int {
|
||||
guard let value else { return Int.max }
|
||||
let digits = value.compactMap { $0.isNumber ? String($0) : " " }.joined()
|
||||
let parts = digits
|
||||
.split(separator: " ")
|
||||
.compactMap { Int($0) }
|
||||
if let min = parts.min() {
|
||||
return min
|
||||
}
|
||||
return Int.max
|
||||
}
|
||||
|
||||
func matchesPriceTier(fee: Double, tier: HomePriceTier) -> Bool {
|
||||
switch tier {
|
||||
case .low: return fee <= 5
|
||||
case .medium: return fee > 5 && fee <= 10
|
||||
case .high: return fee > 10 && fee <= 20
|
||||
case .veryHigh: return fee > 20
|
||||
}
|
||||
}
|
||||
}
|
||||
570
Sources/PediFoods/Views/Main/HomeView.swift
Normal file
@@ -0,0 +1,570 @@
|
||||
import SwiftUI
|
||||
#if os(iOS)
|
||||
import LCEssentials
|
||||
import UIKit
|
||||
#endif
|
||||
|
||||
struct HomeView: View {
|
||||
@Binding var appState: AppState
|
||||
@Binding var selectedTab: MainTab
|
||||
@State var searchText = ""
|
||||
@State var selectedCategory = "all"
|
||||
@State var categories: [CategoryModel] = [
|
||||
.init(id: "all", title: "Todas", systemIcon: "line.3.horizontal.decrease.circle", emojiIcon: nil)
|
||||
]
|
||||
@State var scrollOffset: CGFloat = 0
|
||||
@State var hasRequestedLocation = false
|
||||
@State var isLoadingStores = false
|
||||
@State var storesError: String? = nil
|
||||
@State var stores: [StoreSummary] = []
|
||||
@State var productSearchIndexByStoreId: [String: [String]] = [:]
|
||||
@State var searchDebounceToken = 0
|
||||
@State var favoriteRequestStoreIds: Set<String> = []
|
||||
|
||||
private let specials: [SpecialOfferCardModel] = [
|
||||
// .init(id: "ddddd", title: "Get 50% OFF", subtitle: "For your first order", colors: [Color(hex: "#1E6B43"), Color(hex: "#58A56C")]),
|
||||
// .init(id: "eeeee", title: "Enjoy spicy day", subtitle: "Delivery in 20 min", colors: [Color(hex: "#F04B3E"), Color(hex: "#FF8C42")])
|
||||
]
|
||||
|
||||
private let headerExpandedHeight: CGFloat = 240
|
||||
private let headerCollapsedHeight: CGFloat = 120
|
||||
private let contentTopSpacing: CGFloat = 18
|
||||
private let contentBottomSpacing: CGFloat = 120
|
||||
|
||||
var body: some View {
|
||||
let collapseProgress = clamp(value: scrollOffset / (headerExpandedHeight - headerCollapsedHeight), lower: 0, upper: 1)
|
||||
let headerHeight = headerExpandedHeight - (headerExpandedHeight - headerCollapsedHeight) * collapseProgress
|
||||
|
||||
return ZStack(alignment: .top) {
|
||||
ScrollView(showsIndicators: false) {
|
||||
contentStack
|
||||
.padding(.top, headerExpandedHeight + contentTopSpacing)
|
||||
.padding(.bottom, contentBottomSpacing)
|
||||
}
|
||||
.refreshable {
|
||||
// See StoreDetailView's .refreshable for why this runs in
|
||||
// its own unstructured Task: SwiftUI can cancel
|
||||
// .refreshable's own wrapping Task independent of whether
|
||||
// the network call is still legitimately in flight, and
|
||||
// that cancellation was being silently swallowed by
|
||||
// isCancelledRequest — awaiting Task.value decouples the
|
||||
// real work from that premature cancellation.
|
||||
await Task {
|
||||
await bootstrapStoresFlow(
|
||||
forceNetworkRefresh: true,
|
||||
category: selectedCategoryQueryValue,
|
||||
refreshCategories: true
|
||||
)
|
||||
}.value
|
||||
}
|
||||
.appNamedCoordinateSpace(HomeScrollCoordinateSpace.name)
|
||||
|
||||
// ignoresSafeArea lives here, on the header only — not on the
|
||||
// .refreshable ScrollView above. Applying it to an ancestor of
|
||||
// a .refreshable view (or the view itself) breaks the native
|
||||
// pull-to-refresh spinner's positioning, rendering it invisible
|
||||
// even though the gesture still fires the refresh closure.
|
||||
header(collapseProgress: collapseProgress, height: headerHeight)
|
||||
.frame(maxWidth: .infinity, alignment: .top)
|
||||
.ignoresSafeArea(edges: .top)
|
||||
}
|
||||
.background(AppColors.backgroundLight)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
|
||||
.onAppear {
|
||||
if hasRequestedLocation == false {
|
||||
hasRequestedLocation = true
|
||||
Task {
|
||||
await bootstrapStoresFlow(refreshCategories: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
.onChange(of: addressCacheScope) { _, _ in
|
||||
guard hasRequestedLocation else { return }
|
||||
Task {
|
||||
await bootstrapStoresFlow(
|
||||
forceLocationRefresh: true,
|
||||
category: selectedCategoryQueryValue,
|
||||
refreshCategories: true
|
||||
)
|
||||
}
|
||||
}
|
||||
.onChange(of: searchText) { _, _ in
|
||||
scheduleSearchIndexUpdate()
|
||||
}
|
||||
.onChange(of: appState.homeFilters.sortOption) { _, _ in scheduleSearchIndexUpdate() }
|
||||
.onChange(of: appState.homeFilters.selectedCategories) { _, _ in scheduleSearchIndexUpdate() }
|
||||
.onChange(of: appState.homeFilters.selectedPriceTier) { _, _ in scheduleSearchIndexUpdate() }
|
||||
.onChange(of: appState.homeFilters.maxDistanceKm) { _, _ in scheduleSearchIndexUpdate() }
|
||||
}
|
||||
|
||||
private var contentStack: some View {
|
||||
VStack(spacing: 24) {
|
||||
scrollOffsetObserver
|
||||
|
||||
// The collapsing header is a separate overlay drawn on top of
|
||||
// this ScrollView in the ZStack above, which visually covers
|
||||
// the native pull-to-refresh spinner's position. This gives
|
||||
// refresh feedback that's actually visible, right below the
|
||||
// header, instead of relying on a spinner hidden behind it.
|
||||
if isLoadingStores && stores.isEmpty == false {
|
||||
HStack(spacing: 8) {
|
||||
ProgressView()
|
||||
Text("Atualizando...")
|
||||
.font(AppTypography.caption)
|
||||
.foregroundStyle(AppColors.textMuted)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
|
||||
categoriesSection
|
||||
|
||||
section(title: "Featured") {
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: 16) {
|
||||
ForEach(featuredStoresCards) { store in
|
||||
NavigationLink {
|
||||
StoreDetailView(
|
||||
storeId: store.id,
|
||||
storeName: store.name,
|
||||
storeCoverURL: store.coverURL,
|
||||
storeLogoURL: store.logoURL,
|
||||
storeCategory: store.category,
|
||||
storeRating: store.rating,
|
||||
storeDistance: store.distance,
|
||||
storeDeliveryFee: store.deliveryFee,
|
||||
appState: $appState
|
||||
)
|
||||
} label: {
|
||||
FeaturedStoreCard(
|
||||
store: store,
|
||||
onFavoriteToggle: {
|
||||
Task {
|
||||
await toggleFavoriteStore(storeId: store.id, storeName: store.name)
|
||||
}
|
||||
}
|
||||
)
|
||||
.frame(width: 190)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 20)
|
||||
}
|
||||
}
|
||||
|
||||
if appState.featureFlags.isEnabled("at.promo") {
|
||||
section(title: "#PediPromo") {
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: 16) {
|
||||
ForEach(specials) { item in
|
||||
SpecialOfferCard(model: item)
|
||||
.frame(width: 260, height: 120)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 20)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
section(title: "Pertinho de você") {
|
||||
// Once we have stores loaded, keep showing them regardless of
|
||||
// a subsequent refresh's isLoadingStores/storesError state —
|
||||
// a failed or in-flight pull-to-refresh must never hide
|
||||
// already-loaded content.
|
||||
if filteredStoreCards.isEmpty == false {
|
||||
storeCardsList
|
||||
} else if isLoadingStores {
|
||||
HStack {
|
||||
ProgressView()
|
||||
Text("Buscando estabelecimentos próximos...")
|
||||
.font(AppTypography.body)
|
||||
.foregroundStyle(AppColors.textMuted)
|
||||
}
|
||||
.padding(.horizontal, 20)
|
||||
} else if let storesError {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Text(storesError)
|
||||
.font(AppTypography.body)
|
||||
.foregroundStyle(AppColors.textMuted)
|
||||
Button("Tentar novamente") {
|
||||
Task {
|
||||
await bootstrapStoresFlow(
|
||||
forceLocationRefresh: true,
|
||||
category: selectedCategoryQueryValue,
|
||||
refreshCategories: true
|
||||
)
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.font(AppTypography.heading3)
|
||||
.foregroundStyle(AppColors.primary)
|
||||
}
|
||||
.padding(.horizontal, 20)
|
||||
} else {
|
||||
Text(emptyResultMessage)
|
||||
.font(AppTypography.body)
|
||||
.foregroundStyle(AppColors.textMuted)
|
||||
.padding(.horizontal, 20)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var storeCardsList: some View {
|
||||
VStack(spacing: 16) {
|
||||
ForEach(filteredStoreCards) { store in
|
||||
NavigationLink {
|
||||
StoreDetailView(
|
||||
storeId: store.id,
|
||||
storeName: store.name,
|
||||
storeCoverURL: store.coverURL,
|
||||
storeLogoURL: store.logoURL,
|
||||
storeCategory: store.category,
|
||||
storeRating: store.rating,
|
||||
storeDistance: store.distance,
|
||||
storeDeliveryFee: store.deliveryFee,
|
||||
appState: $appState
|
||||
)
|
||||
} label: {
|
||||
FeaturedStoreCard(
|
||||
store: store,
|
||||
onFavoriteToggle: {
|
||||
Task {
|
||||
await toggleFavoriteStore(storeId: store.id, storeName: store.name)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 20)
|
||||
}
|
||||
|
||||
private func header(collapseProgress: CGFloat, height: CGFloat) -> some View {
|
||||
let titleOpacity = 1 - clamp(value: collapseProgress * 1.2, lower: 0, upper: 1)
|
||||
let topRowOpacity = 1 - clamp(value: collapseProgress * 1.4, lower: 0, upper: 1)
|
||||
|
||||
return ZStack(alignment: .top) {
|
||||
RoundedRectangle(cornerRadius: 32, style: .continuous)
|
||||
.fill(AppColors.primary)
|
||||
.frame(height: height)
|
||||
.overlay(headerRings.opacity(1 - collapseProgress), alignment: .topTrailing)
|
||||
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
Spacer().frame(height: 20)
|
||||
HStack(alignment: .center, spacing: 12) {
|
||||
Button {
|
||||
selectedTab = .profile
|
||||
} label: {
|
||||
Circle()
|
||||
.fill(AppColors.brandSoft)
|
||||
.frame(width: 40, height: 40)
|
||||
.overlay {
|
||||
if let profilePictureURL {
|
||||
AsyncStoreImage(imageURL: profilePictureURL)
|
||||
.frame(width: 36, height: 36)
|
||||
.clipShape(Circle())
|
||||
} else {
|
||||
Image(systemName: "person.fill")
|
||||
.foregroundStyle(AppColors.brandDark)
|
||||
}
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
VStack(alignment: .center, spacing: 4) {
|
||||
Text("ENTREGAR EM:")
|
||||
.font(AppTypography.overline)
|
||||
.tracking(AppTypography.captionLetterSpacing)
|
||||
.foregroundStyle(AppColors.brandSoft)
|
||||
.multilineTextAlignment(.center)
|
||||
|
||||
Button {
|
||||
appState.address.onboardingMessage = nil
|
||||
appState.activeModal = .addressPicker
|
||||
} label: {
|
||||
HStack(spacing: 6) {
|
||||
Text(appState.address.display)
|
||||
.font(AppTypography.heading3)
|
||||
.foregroundStyle(AppColors.textInverse)
|
||||
Image(systemName: "chevron.down")
|
||||
.font(.caption)
|
||||
.foregroundStyle(AppColors.brandSoft)
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .center)
|
||||
|
||||
Circle()
|
||||
.fill(Color.white.opacity(0.18))
|
||||
.frame(width: 40, height: 40)
|
||||
.overlay(
|
||||
Image(systemName: "bell")
|
||||
.foregroundStyle(AppColors.textInverse)
|
||||
)
|
||||
}
|
||||
.opacity(topRowOpacity)
|
||||
.offset(y: collapseProgress * -12)
|
||||
|
||||
if UIDevice().modelName.lowercased().contains("se") || UIDevice().modelName.lowercased().contains("16e") {
|
||||
Text("O que vai querer \npedir hoje?")
|
||||
.font(AppTypography.heading2)
|
||||
.foregroundStyle(AppColors.textInverse)
|
||||
.opacity(titleOpacity)
|
||||
.offset(y: collapseProgress * -20)
|
||||
} else {
|
||||
Text("O que vai querer \npedir hoje?")
|
||||
.font(AppTypography.heading2)
|
||||
.foregroundStyle(AppColors.textInverse)
|
||||
.opacity(titleOpacity)
|
||||
.offset(y: collapseProgress * -20)
|
||||
}
|
||||
|
||||
SearchBar(placeholder: "Search menu, restaurant or craving", text: $searchText) {
|
||||
appState.homeFilters.availableCategories = categories
|
||||
.filter { $0.id.lowercased() != "all" }
|
||||
.map(\.title)
|
||||
appState.activeModal = .filters
|
||||
}
|
||||
.offset(y: collapseProgress * -120)
|
||||
}
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.top, 18)
|
||||
}
|
||||
}
|
||||
|
||||
private func section<Content: View>(title: String, @ViewBuilder content: () -> Content) -> some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
Text(title)
|
||||
.font(AppTypography.heading2)
|
||||
.foregroundStyle(AppColors.textPrimary)
|
||||
.padding(.horizontal, 20)
|
||||
|
||||
content()
|
||||
}
|
||||
}
|
||||
|
||||
private var categoriesSection: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text("Categories")
|
||||
.font(AppTypography.heading2)
|
||||
.foregroundStyle(AppColors.textPrimary)
|
||||
.padding(.horizontal, 20)
|
||||
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: 12) {
|
||||
ForEach(categories) { category in
|
||||
CategoryChip(
|
||||
title: category.title,
|
||||
systemIcon: category.systemIcon,
|
||||
emojiIcon: category.emojiIcon,
|
||||
isActive: category.id == selectedCategory
|
||||
)
|
||||
.onTapGesture {
|
||||
guard category.id != selectedCategory else { return }
|
||||
selectedCategory = category.id
|
||||
Task {
|
||||
await bootstrapStoresFlow(category: category.id.lowercased() == "all" ? nil : category.title)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 20)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var headerRings: some View {
|
||||
ZStack {
|
||||
Circle()
|
||||
.stroke(Color.white.opacity(0.08), lineWidth: 1)
|
||||
.frame(width: 180, height: 180)
|
||||
.offset(x: 40, y: -10)
|
||||
Circle()
|
||||
.stroke(Color.white.opacity(0.08), lineWidth: 1)
|
||||
.frame(width: 130, height: 130)
|
||||
.offset(x: 70, y: 10)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func bootstrapStoresFlow(
|
||||
forceLocationRefresh: Bool = false,
|
||||
forceNetworkRefresh: Bool = false,
|
||||
category: String? = nil,
|
||||
refreshCategories: Bool = false
|
||||
) async {
|
||||
if isLoadingStores { return }
|
||||
// A refresh (pull-to-refresh) that fails must never wipe the list
|
||||
// the user is already looking at — only a first load with nothing
|
||||
// yet loaded is allowed to show a blocking error state.
|
||||
let hadExistingStores = stores.isEmpty == false
|
||||
isLoadingStores = true
|
||||
if hadExistingStores == false {
|
||||
storesError = nil
|
||||
}
|
||||
|
||||
let coordinate = await resolveCoordinates(forceRefresh: forceLocationRefresh)
|
||||
let hasAddress = hasConfiguredAddress()
|
||||
|
||||
if coordinate == nil && hasAddress == false {
|
||||
isLoadingStores = false
|
||||
stores = []
|
||||
storesError = "Defina um endereço para visualizar os estabelecimentos mais próximos."
|
||||
appState.address.onboardingMessage = "Vamos criar um endereço para poder visualizar os estabelecimentos mais próximos de você?"
|
||||
appState.activeModal = .addressPicker
|
||||
return
|
||||
}
|
||||
|
||||
do {
|
||||
let storesCacheKey = homeStoresCacheKey(
|
||||
lat: coordinate?.0,
|
||||
lng: coordinate?.1,
|
||||
category: category
|
||||
)
|
||||
|
||||
if forceLocationRefresh == false,
|
||||
forceNetworkRefresh == false,
|
||||
let cachedStores: [StoreSummary] = AppContentCache.shared.value(for: storesCacheKey, as: [StoreSummary].self) {
|
||||
isLoadingStores = false
|
||||
stores = cachedStores
|
||||
#if os(iOS)
|
||||
for store in cachedStores {
|
||||
printLog(
|
||||
title: "LOGO HOME",
|
||||
msg: "storeId=\(store.id) | storeName=\(store.name) | logoRaw=\(store.logo ?? "nil")"
|
||||
)
|
||||
}
|
||||
#endif
|
||||
AppContentCache.shared.set(cachedStores, for: AppCacheKey.homeStoresLatestSnapshot, ttl: AppCacheTTL.twoHours)
|
||||
if refreshCategories || (category == nil && categories.count <= 1) {
|
||||
await loadHomeCategories(
|
||||
withFallbackStores: cachedStores,
|
||||
forceRefresh: forceLocationRefresh || forceNetworkRefresh
|
||||
)
|
||||
if categories.contains(where: { $0.id == selectedCategory }) == false {
|
||||
selectedCategory = "all"
|
||||
}
|
||||
}
|
||||
storesError = nil
|
||||
return
|
||||
}
|
||||
|
||||
let response = try await ApiService().listStores(
|
||||
lat: coordinate?.0,
|
||||
lng: coordinate?.1,
|
||||
category: category
|
||||
)
|
||||
isLoadingStores = false
|
||||
if response.error {
|
||||
reportStoresLoadFailure(
|
||||
response.message ?? "Não foi possível carregar os estabelecimentos.",
|
||||
hadExistingStores: hadExistingStores
|
||||
)
|
||||
return
|
||||
}
|
||||
let results = response.result ?? []
|
||||
stores = results
|
||||
#if os(iOS)
|
||||
for store in results {
|
||||
printLog(
|
||||
title: "LOGO HOME",
|
||||
msg: "storeId=\(store.id) | storeName=\(store.name) | logoRaw=\(store.logo ?? "nil")"
|
||||
)
|
||||
}
|
||||
#endif
|
||||
AppContentCache.shared.set(results, for: storesCacheKey, ttl: AppCacheTTL.homeStores)
|
||||
AppContentCache.shared.set(results, for: AppCacheKey.homeStoresLatestSnapshot, ttl: AppCacheTTL.twoHours)
|
||||
if refreshCategories || (category == nil && categories.count <= 1) {
|
||||
await loadHomeCategories(
|
||||
withFallbackStores: results,
|
||||
forceRefresh: forceLocationRefresh || forceNetworkRefresh
|
||||
)
|
||||
if categories.contains(where: { $0.id == selectedCategory }) == false {
|
||||
selectedCategory = "all"
|
||||
}
|
||||
}
|
||||
storesError = nil
|
||||
} catch {
|
||||
if isCancelledRequest(error) {
|
||||
isLoadingStores = false
|
||||
return
|
||||
}
|
||||
isLoadingStores = false
|
||||
reportStoresLoadFailure(storesUserMessage(error), hadExistingStores: hadExistingStores)
|
||||
}
|
||||
}
|
||||
|
||||
private func reportStoresLoadFailure(_ message: String, hadExistingStores: Bool) {
|
||||
if hadExistingStores {
|
||||
SnackbarCenter.shared.show(title: message, style: .warning, icon: "exclamationmark.triangle.fill", duration: 3.0)
|
||||
} else {
|
||||
stores = []
|
||||
storesError = message
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var scrollOffsetObserver: some View {
|
||||
ScrollOffsetObserver { y in
|
||||
// Use only upward displacement for collapse and ignore top bounce.
|
||||
let normalized = max(0, y)
|
||||
scrollOffset = normalized
|
||||
}
|
||||
.frame(width: 0, height: 0)
|
||||
}
|
||||
|
||||
private var addressCacheScope: String {
|
||||
let selected = appState.address.selectedId ?? "nil"
|
||||
let display = appState.address.display
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
.lowercased()
|
||||
let lat = appState.address.latitude.map(formatCoordinateScope) ?? "nil"
|
||||
let lng = appState.address.longitude.map(formatCoordinateScope) ?? "nil"
|
||||
return "\(selected)|\(display)|\(lat)|\(lng)"
|
||||
}
|
||||
|
||||
private func homeStoresCacheKey(lat: Double?, lng: Double?, category: String?) -> String {
|
||||
let normalizedCategory = category?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() ?? "all"
|
||||
let latKey = lat.map(formatCoordinateCache) ?? "nil"
|
||||
let lngKey = lng.map(formatCoordinateCache) ?? "nil"
|
||||
return "stores:\(latKey):\(lngKey):\(normalizedCategory)"
|
||||
}
|
||||
|
||||
private func formatCoordinateScope(_ value: Double) -> String {
|
||||
String((value * 100_000).rounded() / 100_000)
|
||||
}
|
||||
|
||||
private func formatCoordinateCache(_ value: Double) -> String {
|
||||
String((value * 10_000).rounded() / 10_000)
|
||||
}
|
||||
|
||||
private var selectedCategoryQueryValue: String? {
|
||||
guard selectedCategory.lowercased() != "all" else { return nil }
|
||||
guard let selected = categories.first(where: { $0.id == selectedCategory }) else { return nil }
|
||||
return selected.title
|
||||
}
|
||||
|
||||
private func isCancelledRequest(_ error: Error) -> Bool {
|
||||
if error is CancellationError {
|
||||
return true
|
||||
}
|
||||
|
||||
if let networkError = error as? NetworkError {
|
||||
switch networkError {
|
||||
case .cancelled:
|
||||
return true
|
||||
case .transportError(let message):
|
||||
let normalized = message.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
return normalized.contains("cancel")
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return error.localizedDescription.lowercased().contains("cancel")
|
||||
}
|
||||
}
|
||||
63
Sources/PediFoods/Views/Main/HomeViewComponents.swift
Normal file
@@ -0,0 +1,63 @@
|
||||
import SwiftUI
|
||||
|
||||
struct CategoryModel: Identifiable {
|
||||
let id: String
|
||||
let title: String
|
||||
let systemIcon: String?
|
||||
let emojiIcon: String?
|
||||
}
|
||||
|
||||
struct CategoryChip: View {
|
||||
let title: String
|
||||
let systemIcon: String?
|
||||
let emojiIcon: String?
|
||||
let isActive: Bool
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 8) {
|
||||
if let emojiIcon, emojiIcon.isEmpty == false {
|
||||
Text(emojiIcon)
|
||||
.font(.body)
|
||||
} else if let systemIcon, systemIcon.isEmpty == false {
|
||||
Image(systemName: systemIcon)
|
||||
.font(.caption)
|
||||
}
|
||||
Text(title)
|
||||
.font(AppTypography.heading3)
|
||||
}
|
||||
.foregroundStyle(isActive ? AppColors.textInverse : AppColors.textPrimary)
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 10)
|
||||
.background(isActive ? AppColors.primary : AppColors.surface)
|
||||
.clipShape(Capsule())
|
||||
}
|
||||
}
|
||||
|
||||
struct SearchBar: View {
|
||||
let placeholder: String
|
||||
@Binding var text: String
|
||||
var onFilterTap: () -> Void = {}
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 12) {
|
||||
Image(systemName: "magnifyingglass")
|
||||
.foregroundStyle(AppColors.textMuted)
|
||||
TextField(placeholder, text: $text)
|
||||
.appNoAutoCap()
|
||||
Spacer()
|
||||
Button(action: onFilterTap) {
|
||||
Image(systemName: "slider.horizontal.3")
|
||||
.foregroundStyle(AppColors.textMuted)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.frame(height: 52)
|
||||
.background(AppColors.surface)
|
||||
.clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous))
|
||||
}
|
||||
}
|
||||
|
||||
func clamp(value: CGFloat, lower: CGFloat, upper: CGFloat) -> CGFloat {
|
||||
min(max(value, lower), upper)
|
||||
}
|
||||
85
Sources/PediFoods/Views/Main/MainTabView.swift
Normal file
@@ -0,0 +1,85 @@
|
||||
import SwiftUI
|
||||
|
||||
struct MainTabView: View {
|
||||
@Binding var selectedTab: MainTab
|
||||
@Binding var root: RootFlow
|
||||
let tokenStore: TokenStore
|
||||
@Binding var appState: AppState
|
||||
|
||||
var body: some View {
|
||||
ZStack(alignment: .bottom) {
|
||||
Group {
|
||||
switch selectedTab {
|
||||
case .home:
|
||||
NavigationStack {
|
||||
HomeView(appState: $appState, selectedTab: $selectedTab)
|
||||
}
|
||||
case .cart:
|
||||
NavigationStack {
|
||||
CartView(appState: $appState, selectedTab: $selectedTab)
|
||||
}
|
||||
case .profile:
|
||||
NavigationStack {
|
||||
ProfileView(root: $root, selectedTab: $selectedTab, tokenStore: tokenStore, appState: $appState)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
customTabBar
|
||||
}
|
||||
}
|
||||
|
||||
private var customTabBar: some View {
|
||||
HStack(spacing: 12) {
|
||||
tabBarButton(tab: .home, title: "Home", icon: "house.fill")
|
||||
tabBarButton(tab: .cart, title: "Carrinho", icon: "cart.fill", badgeCount: appState.cart.totalItems)
|
||||
tabBarButton(tab: .profile, title: "Perfil", icon: "person.fill")
|
||||
}
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 10)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 30, style: .continuous)
|
||||
.fill(AppColors.surface.opacity(0.95))
|
||||
)
|
||||
.padding(.horizontal, 18)
|
||||
.padding(.bottom, 10)
|
||||
.shadow(color: AppShadow.soft.color, radius: AppShadow.soft.radius, y: AppShadow.soft.y)
|
||||
}
|
||||
|
||||
private func tabBarButton(tab: MainTab, title: String, icon: String, badgeCount: Int = 0) -> some View {
|
||||
let isActive = selectedTab == tab
|
||||
return Button {
|
||||
selectedTab = tab
|
||||
} label: {
|
||||
HStack(spacing: 8) {
|
||||
ZStack(alignment: .topTrailing) {
|
||||
Image(systemName: icon)
|
||||
.font(.system(size: 18, weight: .semibold))
|
||||
|
||||
if badgeCount > 0 {
|
||||
Text("\(min(badgeCount, 99))")
|
||||
.font(.system(size: 9, weight: .bold))
|
||||
.foregroundStyle(Color.white)
|
||||
.padding(.horizontal, 4)
|
||||
.padding(.vertical, 2)
|
||||
.background(Color.red)
|
||||
.clipShape(Capsule())
|
||||
.offset(x: 9, y: -8)
|
||||
}
|
||||
}
|
||||
if isActive {
|
||||
Text(title)
|
||||
.font(AppTypography.heading3)
|
||||
}
|
||||
}
|
||||
.foregroundStyle(isActive ? AppColors.textInverse : AppColors.textPrimary)
|
||||
.padding(.horizontal, 18)
|
||||
.padding(.vertical, 10)
|
||||
.background(
|
||||
Capsule()
|
||||
.fill(isActive ? AppColors.primary : Color.clear)
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
622
Sources/PediFoods/Views/Main/OrderDetailsView.swift
Normal file
@@ -0,0 +1,622 @@
|
||||
import SwiftUI
|
||||
|
||||
struct OrderDetailsView: View {
|
||||
let order: PublicOrderResult
|
||||
let orderId: String
|
||||
let initialShortId: String?
|
||||
@Binding var appState: AppState
|
||||
|
||||
@Environment(\.dismiss) var dismiss
|
||||
@Environment(\.openURL) var openURL
|
||||
@State private var storeContactPhone: String? = nil
|
||||
@State private var resolvedStoreLogoURL: String? = nil
|
||||
@State private var showCallAlert = false
|
||||
@State private var navigateToStore = false
|
||||
@State private var showClearCartAlert = false
|
||||
|
||||
var body: some View {
|
||||
ScrollView(showsIndicators: false) {
|
||||
VStack(spacing: 14) {
|
||||
screenHeader
|
||||
statusCard
|
||||
storeCard
|
||||
itemsCard
|
||||
totalsCard
|
||||
if hasAddressInfo {
|
||||
addressCard
|
||||
}
|
||||
helpFooter
|
||||
}
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.top, 14)
|
||||
.padding(.bottom, UIDevice.bottomNotch)
|
||||
}
|
||||
.background(AppColors.backgroundLight)
|
||||
.appHiddenNavigationBar()
|
||||
.navigationBarBackButtonHidden(true)
|
||||
.navigationDestination(isPresented: $navigateToStore) {
|
||||
if let storeId = order.storeId, storeId.isEmpty == false {
|
||||
StoreDetailView(
|
||||
storeId: storeId,
|
||||
storeName: order.storeName ?? "Loja",
|
||||
storeCoverURL: nil,
|
||||
storeLogoURL: order.storeLogoURL,
|
||||
storeCategory: nil,
|
||||
storeRating: nil,
|
||||
storeDistance: nil,
|
||||
storeDeliveryFee: nil,
|
||||
appState: $appState
|
||||
)
|
||||
}
|
||||
}
|
||||
.alert("Ligar para a loja?", isPresented: $showCallAlert) {
|
||||
Button("Ligar para \(order.storeName ?? "a loja")") {
|
||||
if let phone = storeContactPhone {
|
||||
openTel(phone)
|
||||
}
|
||||
}
|
||||
Button("Cancelar", role: .cancel) {}
|
||||
} message: {
|
||||
Text("WhatsApp não encontrado. Deseja ligar para \(order.storeName ?? "a loja")?")
|
||||
}
|
||||
.task {
|
||||
await loadStoreContactPhone()
|
||||
}
|
||||
.alert("Substituir carrinho?", isPresented: $showClearCartAlert) {
|
||||
Button("Limpar e adicionar", role: .destructive) {
|
||||
applyReorder(clearFirst: true)
|
||||
}
|
||||
Button("Cancelar", role: .cancel) {}
|
||||
} message: {
|
||||
Text("Seu carrinho tem itens de \(appState.cart.storeName ?? appState.cart.storeId ?? "outra loja"). Deseja limpar e adicionar itens de \(order.storeName ?? "esta loja")?")
|
||||
}
|
||||
.appBottomSafeAreaInset {
|
||||
VStack {
|
||||
reorderButton
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.top, 10)
|
||||
.padding(.bottom, UIDevice.bottomNotch + 60)
|
||||
}
|
||||
.background(AppColors.backgroundLight.opacity(0.94))
|
||||
}
|
||||
}
|
||||
|
||||
private var screenHeader: some View {
|
||||
ZStack {
|
||||
Text("Detalhes do Pedido")
|
||||
.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 statusCard: some View {
|
||||
HStack(spacing: 14) {
|
||||
Circle()
|
||||
.fill(AppColors.brandSoft)
|
||||
.frame(width: 54, height: 54)
|
||||
.overlay(
|
||||
Image(systemName: statusIcon)
|
||||
.font(.system(size: 22, weight: .bold))
|
||||
.foregroundStyle(statusColor)
|
||||
)
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(statusTitle)
|
||||
.font(AppTypography.heading3)
|
||||
.foregroundStyle(AppColors.textPrimary)
|
||||
|
||||
Text(statusDateText)
|
||||
.font(AppTypography.body)
|
||||
.foregroundStyle(AppColors.textMuted)
|
||||
|
||||
if let reason = cancellationReasonText {
|
||||
Text(reason)
|
||||
.font(AppTypography.caption)
|
||||
.foregroundStyle(AppColors.textMuted)
|
||||
.padding(.top, 2)
|
||||
}
|
||||
|
||||
if let addr = deliveryAddressSummary {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: "mappin.circle.fill")
|
||||
.font(.system(size: 11))
|
||||
.foregroundStyle(AppColors.textMuted)
|
||||
Text(addr)
|
||||
.font(AppTypography.caption)
|
||||
.foregroundStyle(AppColors.textMuted)
|
||||
.lineLimit(2)
|
||||
}
|
||||
.padding(.top, 2)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.padding(16)
|
||||
.background(AppColors.surface)
|
||||
.clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous))
|
||||
}
|
||||
|
||||
private var storeCard: some View {
|
||||
Button {
|
||||
if order.storeId?.isEmpty == false {
|
||||
navigateToStore = true
|
||||
}
|
||||
} label: {
|
||||
HStack(spacing: 12) {
|
||||
AsyncStoreImage(imageURL: resolvedMediaURL(resolvedStoreLogoURL ?? order.storeLogoURL))
|
||||
.frame(width: 54, height: 54)
|
||||
.clipShape(Circle())
|
||||
.background(AppColors.brandSoft, in: Circle())
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(order.storeName?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false ? (order.storeName ?? "Loja") : "Loja")
|
||||
.font(AppTypography.heading2)
|
||||
.foregroundStyle(AppColors.textPrimary)
|
||||
Text(storeSubtitle)
|
||||
.font(AppTypography.body)
|
||||
.foregroundStyle(AppColors.textMuted)
|
||||
}
|
||||
|
||||
Spacer(minLength: 0)
|
||||
|
||||
if order.storeId?.isEmpty == false {
|
||||
Text("Ver loja")
|
||||
.font(AppTypography.body)
|
||||
.foregroundStyle(Color(hex: "#A5D645"))
|
||||
}
|
||||
}
|
||||
.padding(16)
|
||||
.background(AppColors.surface)
|
||||
.clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
private var itemsCard: some View {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
Text("Itens do Pedido")
|
||||
.font(AppTypography.heading2)
|
||||
.foregroundStyle(AppColors.textPrimary)
|
||||
|
||||
ForEach(order.items) { item in
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
Text("\(max(1, item.qty ?? 1))")
|
||||
.font(AppTypography.heading3)
|
||||
.foregroundStyle(AppColors.textPrimary)
|
||||
.frame(width: 30, height: 30)
|
||||
.background(AppColors.backgroundLight)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(item.name?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false ? (item.name ?? "Item") : "Item")
|
||||
.font(AppTypography.heading3)
|
||||
.foregroundStyle(AppColors.textPrimary)
|
||||
}
|
||||
|
||||
Spacer(minLength: 0)
|
||||
|
||||
if let price = item.price {
|
||||
Text(formatCurrency(price))
|
||||
.font(AppTypography.heading3)
|
||||
.foregroundStyle(AppColors.textPrimary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(16)
|
||||
.background(AppColors.surface)
|
||||
.clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous))
|
||||
}
|
||||
|
||||
private var totalsCard: some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Text("Resumo de Valores")
|
||||
.font(AppTypography.heading2)
|
||||
.foregroundStyle(AppColors.textPrimary)
|
||||
|
||||
HStack {
|
||||
Text("Subtotal")
|
||||
.font(AppTypography.body)
|
||||
.foregroundStyle(AppColors.textMuted)
|
||||
Spacer()
|
||||
Text(formatCurrency(subtotalValue))
|
||||
.font(AppTypography.body)
|
||||
.foregroundStyle(AppColors.textMuted)
|
||||
}
|
||||
|
||||
HStack {
|
||||
Text("Taxa de entrega")
|
||||
.font(AppTypography.body)
|
||||
.foregroundStyle(AppColors.textMuted)
|
||||
Spacer()
|
||||
Text(formatCurrency(deliveryFeeValue))
|
||||
.font(AppTypography.body)
|
||||
.foregroundStyle(AppColors.textMuted)
|
||||
}
|
||||
|
||||
HStack {
|
||||
Text("Desconto")
|
||||
.font(AppTypography.body)
|
||||
.foregroundStyle(AppColors.textMuted)
|
||||
Spacer()
|
||||
Text("- \(formatCurrency(discountValue))")
|
||||
.font(AppTypography.body)
|
||||
.foregroundStyle(Color(hex: "#18A957"))
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
HStack {
|
||||
Text("Total")
|
||||
.font(AppTypography.heading2)
|
||||
.foregroundStyle(AppColors.textPrimary)
|
||||
Spacer()
|
||||
Text(formatCurrency(totalValue))
|
||||
.font(AppTypography.heading1)
|
||||
.foregroundStyle(AppColors.textPrimary)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(16)
|
||||
.background(AppColors.surface)
|
||||
.clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous))
|
||||
}
|
||||
|
||||
private var addressCard: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
HStack(spacing: 10) {
|
||||
Circle()
|
||||
.fill(AppColors.backgroundLight)
|
||||
.frame(width: 34, height: 34)
|
||||
.overlay(
|
||||
Image(systemName: "mappin.circle.fill")
|
||||
.font(.system(size: 18, weight: .semibold))
|
||||
.foregroundStyle(AppColors.textMuted)
|
||||
)
|
||||
Text("ENDEREÇO DE ENTREGA")
|
||||
.font(AppTypography.caption)
|
||||
.foregroundStyle(AppColors.textMuted)
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
|
||||
Text(deliveryAddressLine)
|
||||
.font(AppTypography.body)
|
||||
.foregroundStyle(AppColors.textPrimary)
|
||||
|
||||
if deliveryAddressLine2.isEmpty == false {
|
||||
Text(deliveryAddressLine2)
|
||||
.font(AppTypography.body)
|
||||
.foregroundStyle(AppColors.textMuted)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(16)
|
||||
.background(AppColors.surface)
|
||||
.clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous))
|
||||
}
|
||||
|
||||
private var reorderButton: some View {
|
||||
Button("Pedir Novamente") {
|
||||
reorder()
|
||||
}
|
||||
.font(AppTypography.heading2)
|
||||
.foregroundStyle(Color(hex: "#0E1A06"))
|
||||
.frame(maxWidth: .infinity, minHeight: 56)
|
||||
.background(order.items.isEmpty ? Color(hex: "#C8F06E").opacity(0.45) : Color(hex: "#C8F06E"))
|
||||
.clipShape(Capsule())
|
||||
.buttonStyle(.plain)
|
||||
.disabled(order.items.isEmpty)
|
||||
}
|
||||
|
||||
private var helpFooter: some View {
|
||||
Button {
|
||||
handleHelpTap()
|
||||
} label: {
|
||||
Text("Precisa de ajuda com esse pedido?")
|
||||
.font(AppTypography.body)
|
||||
.foregroundStyle(Color(hex: "#A5D645"))
|
||||
.frame(maxWidth: .infinity, alignment: .center)
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
private var subtotal: Double {
|
||||
order.items.reduce(0) { partial, item in
|
||||
partial + (Double(max(1, item.qty ?? 1)) * (item.price ?? 0))
|
||||
}
|
||||
}
|
||||
|
||||
private var subtotalValue: Double {
|
||||
order.subtotal ?? subtotal
|
||||
}
|
||||
|
||||
private var deliveryFeeValue: Double {
|
||||
max(0, order.deliveryFee ?? 0)
|
||||
}
|
||||
|
||||
private var discountValue: Double {
|
||||
max(0, order.discount ?? 0)
|
||||
}
|
||||
|
||||
private var totalValue: Double {
|
||||
if let total = order.total {
|
||||
return total
|
||||
}
|
||||
let calculated = subtotalValue + deliveryFeeValue - discountValue
|
||||
return max(0, calculated)
|
||||
}
|
||||
|
||||
private var hasAddressInfo: Bool {
|
||||
deliveryAddressLine.isEmpty == false || deliveryAddressLine2.isEmpty == false
|
||||
}
|
||||
|
||||
private var deliveryAddressLine: String {
|
||||
guard let address = order.deliveryAddress else { return "" }
|
||||
let street = normalizedText(address.street)
|
||||
let number = normalizedText(address.number)
|
||||
let base = [street, number].filter { $0.isEmpty == false }.joined(separator: ", ")
|
||||
if base.isEmpty == false { return base }
|
||||
return normalizedText(address.label)
|
||||
}
|
||||
|
||||
private var deliveryAddressLine2: String {
|
||||
guard let address = order.deliveryAddress else { return "" }
|
||||
let neighborhood = normalizedText(address.neighborhood)
|
||||
let city = normalizedText(address.city)
|
||||
let state = normalizedText(address.state)
|
||||
let zip = normalizedText(address.zip)
|
||||
return [neighborhood, city, state, zip]
|
||||
.filter { $0.isEmpty == false }
|
||||
.joined(separator: " • ")
|
||||
}
|
||||
|
||||
private var storeSubtitle: String {
|
||||
if deliveryAddressLine2.isEmpty == false {
|
||||
return deliveryAddressLine2
|
||||
}
|
||||
return "Pedido #\(displayOrderTitle)"
|
||||
}
|
||||
|
||||
private var deliveryAddressSummary: String? {
|
||||
if let full = order.fullAddress, full.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false {
|
||||
return full.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
let line1 = deliveryAddressLine
|
||||
let line2 = deliveryAddressLine2
|
||||
let combined = [line1, line2].filter { $0.isEmpty == false }.joined(separator: ", ")
|
||||
return combined.isEmpty ? nil : combined
|
||||
}
|
||||
|
||||
private var cancellationReasonText: String? {
|
||||
guard statusTitle.contains("cancelado"),
|
||||
let reason = order.cancellationReason,
|
||||
reason.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false
|
||||
else { return nil }
|
||||
return "Motivo: \(reason.trimmingCharacters(in: .whitespacesAndNewlines))"
|
||||
}
|
||||
|
||||
private var statusTitle: String {
|
||||
let status = normalized(order.status)
|
||||
if status.contains("CANCEL") { return "Pedido cancelado" }
|
||||
if status.contains("COMPLETED") || status.contains("DELIVERED") {
|
||||
return normalized(order.deliveryType ?? order.deliveryTypeLabel).contains("PICKUP") ? "Pedido retirado" : "Pedido concluído"
|
||||
}
|
||||
if status.contains("DELIVER") || status.contains("ROTA") { return "Pedido em rota" }
|
||||
if status.contains("READY") { return "Pedido pronto" }
|
||||
if status.contains("PREPAR") { return "Pedido em produção" }
|
||||
return "Pedido confirmado"
|
||||
}
|
||||
|
||||
private var statusDateText: String {
|
||||
if let event = order.timeline.first,
|
||||
let date = event.date, date.isEmpty == false {
|
||||
let time = event.time.flatMap { $0.isEmpty ? nil : $0 }
|
||||
let combined = time.map { "\(date) às \($0)" } ?? date
|
||||
return "\(statusDatePrefix) \(combined)"
|
||||
}
|
||||
if let formatted = formatDate(order.updatedAt ?? order.createdAt) {
|
||||
return "\(statusDatePrefix) \(formatted)"
|
||||
}
|
||||
return statusDatePrefix
|
||||
}
|
||||
|
||||
private var statusDatePrefix: String {
|
||||
if statusTitle.contains("cancelado") { return "Cancelado em" }
|
||||
if statusTitle.contains("retirado") { return "Retirado em" }
|
||||
if statusTitle.contains("concluído") { return "Entregue em" }
|
||||
return "Atualizado em"
|
||||
}
|
||||
|
||||
private var statusIcon: String {
|
||||
statusTitle.contains("cancelado") ? "xmark" : "checkmark"
|
||||
}
|
||||
|
||||
private var statusColor: Color {
|
||||
statusTitle.contains("cancelado") ? Color.red : AppColors.primary
|
||||
}
|
||||
|
||||
private var displayOrderTitle: String {
|
||||
if let short = order.shortId, short.isEmpty == false { return short }
|
||||
let orderIdValue = order.id.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if orderIdValue.isEmpty == false { return orderIdValue }
|
||||
if let initialShortId, initialShortId.isEmpty == false { return initialShortId }
|
||||
return String(orderId.prefix(6))
|
||||
}
|
||||
|
||||
private func formatCurrency(_ value: Double) -> String {
|
||||
String(format: "R$ %.2f", value).replacingOccurrences(of: ".", with: ",")
|
||||
}
|
||||
|
||||
private func reorder() {
|
||||
guard order.items.isEmpty == false else { return }
|
||||
let cartStoreId = appState.cart.storeId ?? appState.cart.items.first?.storeId ?? ""
|
||||
let orderStoreId = order.storeId ?? ""
|
||||
let cartHasDifferentStore = cartStoreId.isEmpty == false
|
||||
&& orderStoreId.isEmpty == false
|
||||
&& cartStoreId != orderStoreId
|
||||
&& appState.cart.items.isEmpty == false
|
||||
if cartHasDifferentStore {
|
||||
showClearCartAlert = true
|
||||
} else {
|
||||
applyReorder(clearFirst: false)
|
||||
}
|
||||
}
|
||||
|
||||
private func applyReorder(clearFirst: Bool) {
|
||||
if clearFirst {
|
||||
appState.cart.clear()
|
||||
}
|
||||
let storeId = order.storeId ?? ""
|
||||
if appState.cart.storeId == nil || appState.cart.storeId?.isEmpty == true {
|
||||
appState.cart.storeId = storeId
|
||||
appState.cart.storeName = order.storeName
|
||||
}
|
||||
var addedCount = 0
|
||||
for item in order.items {
|
||||
let name = item.name?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
guard name.isEmpty == false else { continue }
|
||||
let qty = max(1, item.qty ?? 1)
|
||||
let price = item.price ?? 0
|
||||
let cartItem = CartItemState(
|
||||
id: UUID().uuidString,
|
||||
productId: item.productId ?? item.id,
|
||||
storeId: storeId,
|
||||
name: name,
|
||||
imageURL: nil,
|
||||
details: nil,
|
||||
addons: [],
|
||||
quantity: qty,
|
||||
unitPrice: price
|
||||
)
|
||||
appState.cart.add(item: cartItem)
|
||||
addedCount += qty
|
||||
}
|
||||
let label = addedCount == 1 ? "1 item adicionado ao carrinho." : "\(addedCount) itens adicionados ao carrinho."
|
||||
SnackbarCenter.shared.show(title: label, style: .success, icon: "cart.badge.plus", duration: 2.5)
|
||||
}
|
||||
|
||||
private func formatDate(_ isoValue: String?) -> String? {
|
||||
guard let isoValue, isoValue.isEmpty == false else { return nil }
|
||||
|
||||
let iso = ISO8601DateFormatter()
|
||||
let optionSets: [ISO8601DateFormatter.Options] = [
|
||||
[.withInternetDateTime, .withFractionalSeconds],
|
||||
[.withInternetDateTime],
|
||||
[.withFullDate, .withTime, .withColonSeparatorInTime],
|
||||
[.withFullDate, .withTime, .withColonSeparatorInTime, .withTimeZone],
|
||||
[.withFullDate]
|
||||
]
|
||||
var date: Date? = nil
|
||||
for options in optionSets {
|
||||
iso.formatOptions = options
|
||||
if let d = iso.date(from: isoValue) {
|
||||
date = d
|
||||
break
|
||||
}
|
||||
}
|
||||
if date == nil {
|
||||
let fallback = DateFormatter()
|
||||
fallback.locale = Locale(identifier: "en_US_POSIX")
|
||||
for fmt in ["yyyy-MM-dd'T'HH:mm:ss.SSSZ", "yyyy-MM-dd'T'HH:mm:ssZ",
|
||||
"yyyy-MM-dd HH:mm:ss", "yyyy-MM-dd"] {
|
||||
fallback.dateFormat = fmt
|
||||
if let d = fallback.date(from: isoValue) { date = d; break }
|
||||
}
|
||||
}
|
||||
guard let date else { return nil }
|
||||
|
||||
let formatter = DateFormatter()
|
||||
formatter.locale = Locale(identifier: "pt_BR")
|
||||
formatter.dateFormat = "dd MMM, HH:mm"
|
||||
return formatter.string(from: date)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func loadStoreContactPhone() async {
|
||||
if let inline = order.storePhone, inline.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false {
|
||||
storeContactPhone = inline
|
||||
}
|
||||
guard let storeId = order.storeId?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
storeId.isEmpty == false else { return }
|
||||
do {
|
||||
let response = try await ApiService().storeInfo(storeId: storeId)
|
||||
if response.error == false, let result = response.result {
|
||||
let phone = (result.whatsapp ?? result.phone ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if phone.isEmpty == false {
|
||||
storeContactPhone = phone
|
||||
}
|
||||
if let logo = result.logo, logo.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false {
|
||||
resolvedStoreLogoURL = logo
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
private func handleHelpTap() {
|
||||
guard let phoneRaw = storeContactPhone,
|
||||
phoneRaw.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false else {
|
||||
SnackbarCenter.shared.show(
|
||||
title: "Telefone da loja indisponível.",
|
||||
style: .warning,
|
||||
icon: "exclamationmark.triangle.fill",
|
||||
duration: 2.8
|
||||
)
|
||||
return
|
||||
}
|
||||
if let waURL = makeWhatsAppURL(from: phoneRaw) {
|
||||
openURL(waURL)
|
||||
} else {
|
||||
showCallAlert = true
|
||||
}
|
||||
}
|
||||
|
||||
private func openTel(_ phoneRaw: String) {
|
||||
let digits = phoneRaw.filter(\.isNumber)
|
||||
if digits.isEmpty { return }
|
||||
if let url = URL(string: "tel://\(digits)") {
|
||||
openURL(url)
|
||||
}
|
||||
}
|
||||
|
||||
private func makeWhatsAppURL(from phoneRaw: String) -> URL? {
|
||||
var digits = phoneRaw.filter(\.isNumber)
|
||||
if digits.isEmpty { return nil }
|
||||
if digits.hasPrefix("0") { digits = String(digits.drop(while: { $0 == "0" })) }
|
||||
if digits.hasPrefix("55") == false && (digits.count == 10 || digits.count == 11) {
|
||||
digits = "55" + digits
|
||||
}
|
||||
guard digits.count >= 12 else { return nil }
|
||||
return URL(string: "https://wa.me/\(digits)")
|
||||
}
|
||||
|
||||
private func normalized(_ value: String?) -> String {
|
||||
(value ?? "")
|
||||
.folding(options: [.diacriticInsensitive, .caseInsensitive], locale: .current)
|
||||
.uppercased()
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
|
||||
private func resolvedMediaURL(_ raw: String?) -> String? {
|
||||
ImageSourceResolver.resolve(raw)
|
||||
}
|
||||
|
||||
private func normalizedText(_ value: String?) -> String {
|
||||
(value ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
}
|
||||
886
Sources/PediFoods/Views/Main/OrderTrackingView.swift
Normal file
@@ -0,0 +1,886 @@
|
||||
import SwiftUI
|
||||
|
||||
private struct TrackingStep: Identifiable {
|
||||
let id: String
|
||||
let title: String
|
||||
let subtitle: String
|
||||
let time: String?
|
||||
let isCompleted: Bool
|
||||
let isActive: Bool
|
||||
}
|
||||
|
||||
struct OrderTrackingView: View {
|
||||
let orderId: String
|
||||
let initialShortId: String?
|
||||
var postOrderBack: (() -> Void)? = nil
|
||||
@Environment(\.dismiss) var dismiss
|
||||
@Environment(\.openURL) var openURL
|
||||
|
||||
@State var isLoading = true
|
||||
@State var errorMessage: String? = nil
|
||||
@State var order: PublicOrderResult? = nil
|
||||
@State var storeContactPhone: String? = nil
|
||||
@State var tracker = OrderRealtimeTracker()
|
||||
@State var showCancellationReason = false
|
||||
@State var reviewDraft: ReviewDraft? = nil
|
||||
@State var didSaveReviewForCurrentOrder = false
|
||||
@State var reviewSavedObserver: Any?
|
||||
|
||||
var body: some View {
|
||||
ScrollView(showsIndicators: false) {
|
||||
VStack(spacing: 16) {
|
||||
screenHeader
|
||||
topHeader
|
||||
orderTitleSection
|
||||
statusBanner
|
||||
timelineSection
|
||||
placeholderCard
|
||||
if shouldShowReviewButton {
|
||||
reviewButton
|
||||
} else {
|
||||
contactButton
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.top, 14)
|
||||
.padding(.bottom, UIDevice.bottomNotch + 45)
|
||||
}
|
||||
.background(AppColors.backgroundLight)
|
||||
.appHiddenNavigationBar()
|
||||
.navigationBarBackButtonHidden(true)
|
||||
.alert("Motivo do cancelamento", isPresented: $showCancellationReason) {
|
||||
Button("Fechar", role: .cancel) {}
|
||||
} message: {
|
||||
Text(cancellationReasonText)
|
||||
}
|
||||
.task {
|
||||
await loadInitialOrder()
|
||||
tracker.onOrderUpdated = { updated in
|
||||
order = updated
|
||||
if let inlinePhone = updated.storePhone, inlinePhone.isEmpty == false {
|
||||
storeContactPhone = inlinePhone
|
||||
}
|
||||
isLoading = false
|
||||
errorMessage = nil
|
||||
}
|
||||
tracker.start(orderId: orderId, jwt: DefaultTokenStore().jwt)
|
||||
}
|
||||
.onAppear {
|
||||
attachReviewSavedObserverIfNeeded()
|
||||
}
|
||||
.onDisappear {
|
||||
tracker.stop()
|
||||
detachReviewSavedObserver()
|
||||
}
|
||||
.navigationDestination(item: $reviewDraft) { draft in
|
||||
MyReviewsView(initialOrder: draft)
|
||||
}
|
||||
}
|
||||
|
||||
private func attachReviewSavedObserverIfNeeded() {
|
||||
guard reviewSavedObserver == nil else { return }
|
||||
reviewSavedObserver = NotificationCenter.default.addObserver(
|
||||
forName: .orderReviewDidSave,
|
||||
object: nil,
|
||||
queue: nil
|
||||
) { payload in
|
||||
guard let orderIdFromEvent = payload.userInfo?["orderId"] as? String else { return }
|
||||
let currentOrderId = (order?.id ?? orderId)
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
.lowercased()
|
||||
if orderIdFromEvent.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == currentOrderId {
|
||||
didSaveReviewForCurrentOrder = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func detachReviewSavedObserver() {
|
||||
guard let reviewSavedObserver else { return }
|
||||
NotificationCenter.default.removeObserver(reviewSavedObserver)
|
||||
self.reviewSavedObserver = nil
|
||||
}
|
||||
|
||||
private var screenHeader: some View {
|
||||
ZStack {
|
||||
Text("Pedido \(displayOrderTitle)")
|
||||
.font(AppTypography.heading2)
|
||||
.foregroundStyle(AppColors.textPrimary)
|
||||
HStack {
|
||||
Button(action: {
|
||||
if let postOrderBack {
|
||||
postOrderBack()
|
||||
} else {
|
||||
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 topHeader: some View {
|
||||
HStack(spacing: 10) {
|
||||
Circle()
|
||||
.fill(Color.white.opacity(0.2))
|
||||
.frame(width: 28, height: 28)
|
||||
.overlay(
|
||||
Circle()
|
||||
.fill(Color.white.opacity(0.35))
|
||||
.frame(width: 14, height: 14)
|
||||
)
|
||||
Text("Acompanhamento em tempo real")
|
||||
.font(AppTypography.heading2)
|
||||
.foregroundStyle(Color.white)
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 18)
|
||||
.background(AppColors.primary)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 28, style: .continuous))
|
||||
}
|
||||
|
||||
private var orderTitleSection: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text(order?.storeName?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false ? (order?.storeName ?? "Seu pedido") : "Seu pedido")
|
||||
.font(AppTypography.heading1)
|
||||
.foregroundStyle(AppColors.textPrimary)
|
||||
|
||||
Text("Pedido #\(displayOrderTitle)")
|
||||
.font(AppTypography.body)
|
||||
.foregroundStyle(AppColors.textMuted)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var statusBanner: some View {
|
||||
if let errorMessage, errorMessage.isEmpty == false {
|
||||
statusBadge(
|
||||
title: errorMessage,
|
||||
fg: Color.red,
|
||||
bg: Color.red.opacity(0.12),
|
||||
icon: "xmark.octagon.fill"
|
||||
)
|
||||
} else if isLoading {
|
||||
HStack(spacing: 10) {
|
||||
ProgressView()
|
||||
Text("Atualizando status do pedido...")
|
||||
.font(AppTypography.body)
|
||||
.foregroundStyle(AppColors.textMuted)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
} else if isCanceled {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
statusBadge(
|
||||
title: "Pedido cancelado",
|
||||
fg: Color.red,
|
||||
bg: Color.red.opacity(0.12),
|
||||
icon: "xmark.circle.fill"
|
||||
)
|
||||
if cancellationReasonText.isEmpty == false {
|
||||
Button("Ver motivo do cancelamento") {
|
||||
showCancellationReason = true
|
||||
}
|
||||
.font(AppTypography.heading3)
|
||||
.foregroundStyle(Color.red)
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
} else if isWaitingPayment {
|
||||
statusBadge(
|
||||
title: "Aguardando pagamento",
|
||||
fg: Color(hex: "#A16207"),
|
||||
bg: Color(hex: "#FDE68A").opacity(0.35),
|
||||
icon: "clock.fill"
|
||||
)
|
||||
} else {
|
||||
statusBadge(
|
||||
title: successBannerTitle,
|
||||
fg: AppColors.primary,
|
||||
bg: AppColors.brandSoft,
|
||||
icon: "checkmark.circle.fill"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private var timelineSection: some View {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
Text("Progresso do Pedido")
|
||||
.font(AppTypography.heading2)
|
||||
.foregroundStyle(AppColors.textPrimary)
|
||||
|
||||
if let order {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
ForEach(Array(timelineSteps(for: order).enumerated()), id: \.element.id) { index, step in
|
||||
timelineRow(step: step, isLast: index == timelineSteps(for: order).count - 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(16)
|
||||
.background(AppColors.surface)
|
||||
.clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous))
|
||||
}
|
||||
|
||||
private var placeholderCard: some View {
|
||||
VStack(spacing: 12) {
|
||||
HStack(spacing: 10) {
|
||||
Circle()
|
||||
.fill(AppColors.brandSoft)
|
||||
.frame(width: 28, height: 28)
|
||||
.overlay(
|
||||
Image(systemName: summaryStatusIcon)
|
||||
.font(.system(size: 12, weight: .bold))
|
||||
.foregroundStyle(AppColors.primary)
|
||||
)
|
||||
Text(summaryStatusTitle)
|
||||
.font(AppTypography.heading3)
|
||||
.foregroundStyle(AppColors.textPrimary)
|
||||
Spacer()
|
||||
}
|
||||
|
||||
Group {
|
||||
if hasTrackingImage {
|
||||
Image(trackingImageName)
|
||||
.renderingMode(.original)
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
} else if hasPlaceholderProductImage {
|
||||
Image("placeholder-product")
|
||||
.renderingMode(.original)
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
} else {
|
||||
ZStack {
|
||||
Color.black.opacity(0.08)
|
||||
Image(systemName: "shippingbox.fill")
|
||||
.font(.system(size: 52, weight: .bold))
|
||||
.foregroundStyle(AppColors.primary)
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(height: 220)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 18, style: .continuous))
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(16)
|
||||
.background(AppColors.surface)
|
||||
.clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous))
|
||||
}
|
||||
|
||||
private var contactButton: some View {
|
||||
Button("CONTATO") {
|
||||
openStoreWhatsApp()
|
||||
}
|
||||
.font(AppTypography.heading2)
|
||||
.foregroundStyle(AppColors.textPrimary)
|
||||
.frame(maxWidth: .infinity, minHeight: 56)
|
||||
.background(AppColors.surface)
|
||||
.clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous))
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
private var reviewButton: some View {
|
||||
Button("AVALIAR PEDIDO") {
|
||||
guard let reviewTargetDraft else { return }
|
||||
reviewDraft = reviewTargetDraft
|
||||
}
|
||||
.font(AppTypography.heading2)
|
||||
.foregroundStyle(Color(hex: "#0E1A06"))
|
||||
.frame(maxWidth: .infinity, minHeight: 56)
|
||||
.background(Color(hex: "#7CF02A"))
|
||||
.clipShape(Capsule())
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
private func statusBadge(title: String, fg: Color, bg: Color, icon: String) -> some View {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: icon)
|
||||
.font(.system(size: 14, weight: .bold))
|
||||
Text(title)
|
||||
.font(AppTypography.heading3)
|
||||
}
|
||||
.foregroundStyle(fg)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 8)
|
||||
.background(bg)
|
||||
.clipShape(Capsule())
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
|
||||
private func timelineRow(step: TrackingStep, isLast: Bool) -> some View {
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
VStack(spacing: 0) {
|
||||
Circle()
|
||||
.fill(stepDotColor(step))
|
||||
.frame(width: 20, height: 20)
|
||||
.overlay(
|
||||
Group {
|
||||
if step.isCompleted {
|
||||
Image(systemName: "checkmark")
|
||||
.font(.system(size: 11, weight: .bold))
|
||||
.foregroundStyle(.white)
|
||||
} else if step.isActive {
|
||||
Circle()
|
||||
.fill(.white)
|
||||
.frame(width: 8, height: 8)
|
||||
} else {
|
||||
Circle()
|
||||
.stroke(Color(hex: "#C5CBD4"), lineWidth: 2)
|
||||
.frame(width: 8, height: 8)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
if isLast == false {
|
||||
Rectangle()
|
||||
.fill(stepLineColor(step))
|
||||
.frame(width: 2, height: 36)
|
||||
}
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(step.title)
|
||||
.font(AppTypography.heading2)
|
||||
.foregroundStyle(stepTitleColor(step))
|
||||
|
||||
if step.subtitle.isEmpty == false {
|
||||
Text(step.subtitle)
|
||||
.font(AppTypography.body)
|
||||
.foregroundStyle(stepSubtitleColor(step))
|
||||
}
|
||||
|
||||
if let time = step.time, time.isEmpty == false {
|
||||
Text(time)
|
||||
.font(AppTypography.caption)
|
||||
.foregroundStyle(AppColors.textMuted)
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
|
||||
private func stepDotColor(_ step: TrackingStep) -> Color {
|
||||
if isCanceled {
|
||||
return Color(hex: "#C5CBD4")
|
||||
}
|
||||
if isWaitingPayment && step.id == "paid" {
|
||||
return Color(hex: "#F59E0B")
|
||||
}
|
||||
if step.isCompleted || step.isActive {
|
||||
return AppColors.primary
|
||||
}
|
||||
return Color(hex: "#E5E7EB")
|
||||
}
|
||||
|
||||
private func stepLineColor(_ step: TrackingStep) -> Color {
|
||||
if isCanceled { return Color(hex: "#E5E7EB") }
|
||||
if step.isCompleted || step.isActive {
|
||||
return AppColors.primary.opacity(0.85)
|
||||
}
|
||||
return Color(hex: "#E5E7EB")
|
||||
}
|
||||
|
||||
private func stepTitleColor(_ step: TrackingStep) -> Color {
|
||||
if isCanceled { return Color(hex: "#9CA3AF") }
|
||||
if step.isCompleted || step.isActive {
|
||||
return AppColors.textPrimary
|
||||
}
|
||||
return Color(hex: "#9CA3AF")
|
||||
}
|
||||
|
||||
private func stepSubtitleColor(_ step: TrackingStep) -> Color {
|
||||
if isCanceled { return Color(hex: "#9CA3AF") }
|
||||
if isWaitingPayment && step.id == "paid" {
|
||||
return Color(hex: "#A16207")
|
||||
}
|
||||
if step.isCompleted || step.isActive {
|
||||
return AppColors.primary
|
||||
}
|
||||
return Color(hex: "#9CA3AF")
|
||||
}
|
||||
|
||||
private func timelineSteps(for order: PublicOrderResult) -> [TrackingStep] {
|
||||
let isPickup = normalized(order.deliveryType ?? order.deliveryTypeLabel).contains("PICKUP")
|
||||
let allSteps: [(id: String, title: String, subtitle: String, statuses: [String])] = [
|
||||
("confirmed", "Pedido confirmado", "Seu pedido foi recebido", ["PENDING", "ACCEPTED", "PAYMENT_PENDING"]),
|
||||
("preparing", "Pedido em produção", "Seu pedido está sendo preparado", ["PREPARING"]),
|
||||
("ready", isPickup ? "Pronto para retirada" : "Pronto para entrega", isPickup ? "Retire no balcão quando avisarmos" : "Pedido pronto para sair", ["READY"]),
|
||||
("delivering", "Em rota de entrega", customerOtpSubtitle, ["DELIVERING", "OUT_FOR_DELIVERY", "EM_ROTA", "ON_ROUTE"]),
|
||||
("completed", isPickup ? "Retirado" : "Entregue", "Pedido finalizado", ["COMPLETED", "DELIVERED"])
|
||||
]
|
||||
|
||||
let stepsBase = isPickup ? allSteps.filter { $0.id != "delivering" } : allSteps
|
||||
let currentIndex = currentStepIndex(isPickup: isPickup)
|
||||
|
||||
return stepsBase.enumerated().map { index, step in
|
||||
let event = timelineEvent(for: order, statuses: step.statuses)
|
||||
let isCompleted = event?.completed ?? (isCanceled == false && index < currentIndex)
|
||||
let isActive = event?.active ?? (isCanceled == false && index == currentIndex)
|
||||
return TrackingStep(
|
||||
id: step.id,
|
||||
title: step.title,
|
||||
subtitle: timelineSubtitle(stepId: step.id, fallback: step.subtitle, eventLabel: event?.label),
|
||||
time: formatTime(event?.time),
|
||||
isCompleted: isCompleted || (index == currentIndex && isCompletedTerminalStep(index: index, isPickup: isPickup)),
|
||||
isActive: isActive && isCompletedTerminalStep(index: index, isPickup: isPickup) == false
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func currentStepIndex(isPickup: Bool) -> Int {
|
||||
let normalizedStatus = normalized(order?.status)
|
||||
|
||||
if isCanceled {
|
||||
if normalizedStatus.contains("DELIVER") || normalizedStatus.contains("ROTA") { return isPickup ? 2 : 3 }
|
||||
if normalizedStatus.contains("READY") { return 2 }
|
||||
if normalizedStatus.contains("PREPAR") || normalizedStatus.contains("ACCEPT") || normalizedStatus.contains("PENDING") { return 1 }
|
||||
return 0
|
||||
}
|
||||
|
||||
if isWaitingPayment {
|
||||
return 0
|
||||
}
|
||||
|
||||
if let timelineIndex = timelineProgressStepIndex(isPickup: isPickup) {
|
||||
return timelineIndex
|
||||
}
|
||||
|
||||
if normalizedStatus.contains("COMPLETED") || normalizedStatus.contains("DELIVERED") {
|
||||
return isPickup ? 3 : 4
|
||||
}
|
||||
if isPickup {
|
||||
if normalizedStatus.contains("READY") { return 2 }
|
||||
if normalizedStatus.contains("PREPAR") { return 1 }
|
||||
return 0
|
||||
}
|
||||
|
||||
if normalizedStatus.contains("DELIVER") || normalizedStatus.contains("ROTA") {
|
||||
return 3
|
||||
}
|
||||
if normalizedStatus.contains("READY") {
|
||||
return 2
|
||||
}
|
||||
if normalizedStatus.contains("PREPAR") {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
private func timelineProgressStepIndex(isPickup: Bool) -> Int? {
|
||||
guard let order else { return nil }
|
||||
|
||||
let stepStatuses: [[String]] = isPickup
|
||||
? [
|
||||
["PENDING", "ACCEPTED", "PAYMENT_PENDING"],
|
||||
["PREPARING"],
|
||||
["READY"],
|
||||
["COMPLETED", "DELIVERED"]
|
||||
]
|
||||
: [
|
||||
["PENDING", "ACCEPTED", "PAYMENT_PENDING"],
|
||||
["PREPARING"],
|
||||
["READY"],
|
||||
["DELIVERING", "OUT_FOR_DELIVERY", "EM_ROTA", "ON_ROUTE"],
|
||||
["COMPLETED", "DELIVERED"]
|
||||
]
|
||||
|
||||
var strongestIndex: Int? = nil
|
||||
var fallbackIndex: Int? = nil
|
||||
|
||||
for (index, statuses) in stepStatuses.enumerated() {
|
||||
let statusSet = Set(statuses.map(normalized))
|
||||
let events = order.timeline.filter { event in
|
||||
statusSet.contains(normalized(event.status))
|
||||
}
|
||||
guard events.isEmpty == false else { continue }
|
||||
|
||||
fallbackIndex = index
|
||||
|
||||
if events.contains(where: { $0.active == true || $0.completed == true }) {
|
||||
strongestIndex = index
|
||||
}
|
||||
}
|
||||
|
||||
return strongestIndex ?? fallbackIndex
|
||||
}
|
||||
|
||||
private func isCompletedTerminalStep(index: Int, isPickup: Bool) -> Bool {
|
||||
let terminalIndex = isPickup ? 3 : 4
|
||||
return isCanceled == false && currentStepIndex(isPickup: isPickup) >= terminalIndex && index == terminalIndex
|
||||
}
|
||||
|
||||
private func timelineEvent(for order: PublicOrderResult, statuses: [String]) -> PublicOrderTimelineEvent? {
|
||||
let statusSet = Set(statuses.map(normalized))
|
||||
return order.timeline.first(where: { statusSet.contains(normalized($0.status)) })
|
||||
}
|
||||
|
||||
private func timelineSubtitle(stepId: String, fallback: String, eventLabel: String?) -> String {
|
||||
if stepId == "delivering", customerOtpCode != nil {
|
||||
return customerOtpSubtitle
|
||||
}
|
||||
let label = (eventLabel ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if shouldUseTimelineEventLabel(label, fallback: fallback) {
|
||||
return label
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
private func shouldUseTimelineEventLabel(_ label: String, fallback: String) -> Bool {
|
||||
guard label.isEmpty == false else { return false }
|
||||
|
||||
let foldedLabel = label
|
||||
.folding(options: [.diacriticInsensitive, .caseInsensitive], locale: .current)
|
||||
.lowercased()
|
||||
let foldedFallback = fallback
|
||||
.folding(options: [.diacriticInsensitive, .caseInsensitive], locale: .current)
|
||||
.lowercased()
|
||||
|
||||
if foldedLabel == foldedFallback { return false }
|
||||
|
||||
let englishHints = [
|
||||
"order",
|
||||
"confirmed",
|
||||
"in progress",
|
||||
"progress",
|
||||
"delivery",
|
||||
"delivered",
|
||||
"ready",
|
||||
"sent",
|
||||
"out for",
|
||||
"began"
|
||||
]
|
||||
if englishHints.contains(where: { foldedLabel.contains($0) }) {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
private var customerOtpSubtitle: String {
|
||||
if let otp = customerOtpCode {
|
||||
return "Código para o entregador: \(otp)"
|
||||
}
|
||||
return "Aguardando saída para entrega"
|
||||
}
|
||||
|
||||
private var customerOtpCode: String? {
|
||||
let raw = (order?.customerOtp ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if raw.isEmpty { return nil }
|
||||
let digits = raw.filter(\.isNumber)
|
||||
if digits.count == 4 {
|
||||
return digits
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private var displayOrderTitle: String {
|
||||
if let short = order?.shortId, short.isEmpty == false { return short }
|
||||
if let orderIdValue = order?.id, orderIdValue.isEmpty == false { return orderIdValue }
|
||||
if let initialShortId, initialShortId.isEmpty == false { return initialShortId }
|
||||
return String(orderId.prefix(6))
|
||||
}
|
||||
|
||||
private var isCanceled: Bool {
|
||||
normalized(order?.status).contains("CANCEL")
|
||||
}
|
||||
|
||||
private var isWaitingPayment: Bool {
|
||||
let paymentStatus = normalized(order?.paymentStatus)
|
||||
if isOnlinePaymentMethod == false {
|
||||
return false
|
||||
}
|
||||
if paymentStatus == "PENDING" {
|
||||
return true
|
||||
}
|
||||
return order?.isPaymentConfirmed == false
|
||||
}
|
||||
|
||||
private var isOnlinePaymentMethod: Bool {
|
||||
let code = normalized(order?.paymentMethodCode)
|
||||
if code == "PIX" || code == "CREDIT_CARD" {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private var successBannerTitle: String {
|
||||
if isCompletedOrder {
|
||||
if isPickupOrder {
|
||||
return "Pedido retirado"
|
||||
}
|
||||
return "Pedido entregue"
|
||||
}
|
||||
if isOnlinePaymentMethod {
|
||||
return "Pagamento confirmado"
|
||||
}
|
||||
return "Pedido confirmado"
|
||||
}
|
||||
|
||||
private var summaryStatusTitle: String {
|
||||
if isCanceled {
|
||||
return "Seu pedido foi cancelado"
|
||||
}
|
||||
if isWaitingPayment {
|
||||
return "Aguardando confirmação de pagamento"
|
||||
}
|
||||
if isCompletedOrder {
|
||||
return isPickupOrder ? "Seu pedido foi retirado" : "Seu pedido foi entregue"
|
||||
}
|
||||
return "Seu pedido está em andamento"
|
||||
}
|
||||
|
||||
private var summaryStatusIcon: String {
|
||||
if isCanceled {
|
||||
return "xmark"
|
||||
}
|
||||
if isWaitingPayment {
|
||||
return "clock.fill"
|
||||
}
|
||||
return "checkmark"
|
||||
}
|
||||
|
||||
private var isPickupOrder: Bool {
|
||||
normalized(order?.deliveryType ?? order?.deliveryTypeLabel).contains("PICKUP")
|
||||
}
|
||||
|
||||
private var isCompletedOrder: Bool {
|
||||
let status = normalized(order?.status)
|
||||
if status.contains("COMPLETED") || status.contains("DELIVERED") {
|
||||
return true
|
||||
}
|
||||
|
||||
let stepIndex = currentStepIndex(isPickup: isPickupOrder)
|
||||
let terminalIndex = isPickupOrder ? 3 : 4
|
||||
return isCanceled == false && isWaitingPayment == false && stepIndex >= terminalIndex
|
||||
}
|
||||
|
||||
private var shouldShowReviewButton: Bool {
|
||||
guard isCompletedOrder else { return false }
|
||||
guard isCanceled == false else { return false }
|
||||
guard let reviewTargetDraft else { return false }
|
||||
if didSaveReviewForCurrentOrder { return false }
|
||||
if hasPersistedReviewForCurrentOrder { return false }
|
||||
return order?.review == nil
|
||||
}
|
||||
|
||||
private var hasPersistedReviewForCurrentOrder: Bool {
|
||||
reviewIdCandidates.contains { candidate in
|
||||
SessionStateStore.hasOrderReview(orderId: candidate)
|
||||
}
|
||||
}
|
||||
|
||||
private var reviewIdCandidates: [String] {
|
||||
let values = [
|
||||
orderId,
|
||||
order?.id,
|
||||
order?.realId,
|
||||
order?.shortId
|
||||
]
|
||||
var unique: [String] = []
|
||||
var seen = Set<String>()
|
||||
for raw in values {
|
||||
let normalized = (raw ?? "")
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
.lowercased()
|
||||
guard normalized.isEmpty == false, seen.contains(normalized) == false else { continue }
|
||||
seen.insert(normalized)
|
||||
unique.append(normalized)
|
||||
}
|
||||
return unique
|
||||
}
|
||||
|
||||
private var reviewTargetDraft: ReviewDraft? {
|
||||
let idFromOrder = (order?.id ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let id = idFromOrder.isEmpty ? orderId : idFromOrder
|
||||
guard id.isEmpty == false else { return nil }
|
||||
|
||||
return ReviewDraft(
|
||||
orderId: id,
|
||||
storeId: order?.storeId,
|
||||
shortId: order?.shortId ?? initialShortId,
|
||||
storeName: order?.storeName,
|
||||
storeLogoURL: order?.storeLogoURL,
|
||||
createdAt: order?.createdAt,
|
||||
total: order?.total
|
||||
)
|
||||
}
|
||||
|
||||
private var cancellationReasonText: String {
|
||||
let value = (order?.cancellationReason ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return value.isEmpty ? "Sem detalhe informado." : value
|
||||
}
|
||||
|
||||
private var hasTrackingImage: Bool {
|
||||
imageResourceExists(trackingImageName)
|
||||
}
|
||||
|
||||
private var hasPlaceholderProductImage: Bool {
|
||||
imageResourceExists("placeholder-product")
|
||||
}
|
||||
|
||||
private func imageResourceExists(_ name: String) -> Bool {
|
||||
let exts = ["png", "jpg", "jpeg", "webp"]
|
||||
for ext in exts {
|
||||
if Bundle.main.url(forResource: name, withExtension: ext) != nil {
|
||||
return true
|
||||
}
|
||||
if Bundle.module.url(forResource: name, withExtension: ext) != nil {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private var trackingImageName: String {
|
||||
let isPickup = normalized(order?.deliveryType ?? order?.deliveryTypeLabel).contains("PICKUP")
|
||||
let stepIndex = currentStepIndex(isPickup: isPickup)
|
||||
|
||||
if isCanceled {
|
||||
return "tracking-canceled"
|
||||
}
|
||||
if isWaitingPayment {
|
||||
return "tracking-pending"
|
||||
}
|
||||
switch stepIndex {
|
||||
case 0:
|
||||
return "tracking-pending"
|
||||
case 1:
|
||||
return "tracking-preparing"
|
||||
case 2:
|
||||
return "tracking-ready"
|
||||
case 3:
|
||||
return "tracking-delivering"
|
||||
default:
|
||||
return "tracking-completed"
|
||||
}
|
||||
}
|
||||
|
||||
private func normalized(_ value: String?) -> String {
|
||||
(value ?? "")
|
||||
.folding(options: [.diacriticInsensitive, .caseInsensitive], locale: .current)
|
||||
.uppercased()
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
|
||||
private func formatTime(_ rawValue: String?) -> String? {
|
||||
guard let rawValue else { return nil }
|
||||
let value = rawValue.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if value.isEmpty { return nil }
|
||||
|
||||
if value.contains("T"), let isoTime = formatISOTime(value) {
|
||||
return isoTime
|
||||
}
|
||||
if value.range(of: #"^\d{2}:\d{2}"#, options: .regularExpression) != nil {
|
||||
return String(value.prefix(5))
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
private func formatISOTime(_ value: String) -> String? {
|
||||
let iso = ISO8601DateFormatter()
|
||||
iso.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
||||
var date = iso.date(from: value)
|
||||
if date == nil {
|
||||
iso.formatOptions = [.withInternetDateTime]
|
||||
date = iso.date(from: value)
|
||||
}
|
||||
guard let date else { return nil }
|
||||
let formatter = DateFormatter()
|
||||
formatter.locale = Locale(identifier: "pt_BR")
|
||||
formatter.dateFormat = "HH:mm"
|
||||
return formatter.string(from: date)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func loadInitialOrder() async {
|
||||
logger.info("OrderTracking initial fetch orderId=\(orderId)")
|
||||
|
||||
do {
|
||||
let response = try await ApiService().publicOrder(orderId: orderId)
|
||||
if response.error {
|
||||
errorMessage = response.message ?? "Não foi possível carregar o pedido."
|
||||
logger.error("OrderTracking initial fetch API error orderId=\(orderId) message=\(response.message ?? "unknown")")
|
||||
} else if let result = response.result {
|
||||
order = result
|
||||
storeContactPhone = result.storePhone
|
||||
errorMessage = nil
|
||||
logger.info("OrderTracking initial fetch success orderId=\(orderId) status=\(result.status ?? "nil") paymentStatus=\(result.paymentStatus ?? "nil")")
|
||||
await refreshStoreContactPhone(for: result)
|
||||
}
|
||||
} catch {
|
||||
errorMessage = "Não foi possível carregar o pedido."
|
||||
logger.error("OrderTracking initial fetch failure orderId=\(orderId) error=\(error.localizedDescription)")
|
||||
}
|
||||
|
||||
isLoading = false
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func refreshStoreContactPhone(for order: PublicOrderResult) async {
|
||||
guard let storeId = order.storeId?.trimmingCharacters(in: .whitespacesAndNewlines), storeId.isEmpty == false else {
|
||||
if let inlinePhone = order.storePhone, inlinePhone.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false {
|
||||
storeContactPhone = inlinePhone
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
do {
|
||||
let response = try await ApiService().storeInfo(storeId: storeId)
|
||||
if response.error == false, let result = response.result {
|
||||
let phone = (result.whatsapp ?? result.phone ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if phone.isEmpty == false {
|
||||
storeContactPhone = phone
|
||||
return
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Fallback handled below.
|
||||
}
|
||||
|
||||
if let inlinePhone = order.storePhone, inlinePhone.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false {
|
||||
storeContactPhone = inlinePhone
|
||||
}
|
||||
}
|
||||
|
||||
private func openStoreWhatsApp() {
|
||||
guard let phoneRaw = storeContactPhone,
|
||||
let url = makeWhatsAppURL(from: phoneRaw) else {
|
||||
SnackbarCenter.shared.show(
|
||||
title: "Telefone da loja indisponível.",
|
||||
style: .warning,
|
||||
icon: "exclamationmark.triangle.fill",
|
||||
duration: 2.8
|
||||
)
|
||||
return
|
||||
}
|
||||
openURL(url)
|
||||
}
|
||||
|
||||
private func makeWhatsAppURL(from phoneRaw: String) -> URL? {
|
||||
var digits = phoneRaw.filter(\.isNumber)
|
||||
if digits.isEmpty { return nil }
|
||||
|
||||
if digits.hasPrefix("0") {
|
||||
digits = String(digits.drop(while: { $0 == "0" }))
|
||||
}
|
||||
|
||||
if digits.hasPrefix("55") == false && (digits.count == 10 || digits.count == 11) {
|
||||
digits = "55" + digits
|
||||
}
|
||||
|
||||
guard digits.count >= 12 else { return nil }
|
||||
return URL(string: "https://wa.me/\(digits)")
|
||||
}
|
||||
}
|
||||
886
Sources/PediFoods/Views/Main/OrdersView.swift
Normal file
@@ -0,0 +1,886 @@
|
||||
import SwiftUI
|
||||
|
||||
struct OrdersView: View {
|
||||
@Binding var appState: AppState
|
||||
@Environment(\.dismiss) var dismiss
|
||||
@State var isLoading = false
|
||||
@State var errorMessage: String? = nil
|
||||
@State var orders: [AppOrderSummary] = []
|
||||
@State var hasLoadedOnce = false
|
||||
@State var storeRatingByStoreId: [String: Double] = [:]
|
||||
@State var storeRatingByStoreName: [String: Double] = [:]
|
||||
@State var storeLogoByStoreId: [String: String] = [:]
|
||||
@State var storeLogoByStoreName: [String: String] = [:]
|
||||
@State var selectedOrderRoute: OrderRouteContext? = nil
|
||||
|
||||
var body: some View {
|
||||
ScrollView(showsIndicators: false) {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
screenHeader(title: "Meus Pedidos", onBack: { dismiss() })
|
||||
|
||||
if isLoading {
|
||||
ProgressView()
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.top, 24)
|
||||
} else if let errorMessage, errorMessage.isEmpty == false {
|
||||
Text(errorMessage)
|
||||
.font(AppTypography.body)
|
||||
.foregroundStyle(Color.red)
|
||||
.multilineTextAlignment(.center)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.top, 24)
|
||||
} else if orders.isEmpty {
|
||||
Text("Nenhum pedido encontrado.")
|
||||
.font(AppTypography.body)
|
||||
.foregroundStyle(AppColors.textMuted)
|
||||
.padding(.top, 24)
|
||||
} else {
|
||||
ForEach(orders) { order in
|
||||
orderCard(order)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.top, 18)
|
||||
.padding(.bottom, UIDevice.bottomNotch + 18)
|
||||
}
|
||||
.background(AppColors.backgroundLight)
|
||||
.navigationBarBackButtonHidden(true)
|
||||
.appHiddenNavigationBar()
|
||||
.task {
|
||||
await loadOrdersIfNeeded()
|
||||
await refreshStoreRatings()
|
||||
}
|
||||
.refreshable {
|
||||
// Decoupled from .refreshable's own cancellable wrapping Task —
|
||||
// see StoreDetailView's .refreshable for why.
|
||||
await Task {
|
||||
await loadOrders(force: true)
|
||||
await refreshStoreRatings()
|
||||
}.value
|
||||
}
|
||||
.navigationDestination(item: $selectedOrderRoute) { context in
|
||||
OrderEntryDestinationView(
|
||||
orderId: context.orderId,
|
||||
initialShortId: context.shortId,
|
||||
fallbackPaymentMethod: context.paymentMethod,
|
||||
fallbackTotal: context.total,
|
||||
routeIntent: context.intent,
|
||||
appState: $appState
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
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 orderCard(_ order: AppOrderSummary) -> some View {
|
||||
let status = orderVisualStatus(for: order)
|
||||
let detailsRoute = OrderRouteContext(
|
||||
orderId: trackingOrderId(for: order),
|
||||
shortId: order.shortId,
|
||||
paymentMethod: order.paymentMethod,
|
||||
total: order.total,
|
||||
intent: .details
|
||||
)
|
||||
let trackingRoute = OrderRouteContext(
|
||||
orderId: trackingOrderId(for: order),
|
||||
shortId: order.shortId,
|
||||
paymentMethod: order.paymentMethod,
|
||||
total: order.total,
|
||||
intent: .tracking
|
||||
)
|
||||
|
||||
return VStack(alignment: .leading, spacing: 14) {
|
||||
HStack(spacing: 12) {
|
||||
AsyncStoreImage(imageURL: resolvedMediaURL(storeLogoURL(for: order)))
|
||||
.frame(width: 80, height: 80)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 20, style: .continuous))
|
||||
|
||||
VStack(alignment: .leading, spacing: 5) {
|
||||
|
||||
HStack(spacing: 6) {
|
||||
Text(order.storeName?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false ? (order.storeName ?? "Pedido") : "Pedido #\(order.shortId ?? order.id)")
|
||||
.font(AppTypography.heading2)
|
||||
.minimumScaleFactor(0.01)
|
||||
.foregroundStyle(AppColors.textPrimary)
|
||||
.lineLimit(1)
|
||||
Spacer()
|
||||
Text(status.badgeTitle)
|
||||
.font(AppTypography.caption)
|
||||
.minimumScaleFactor(0.01)
|
||||
.foregroundStyle(status.badgeForeground)
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 6)
|
||||
.background(status.badgeBackground)
|
||||
.clipShape(Capsule())
|
||||
}
|
||||
|
||||
HStack(spacing: 6) {
|
||||
Text(orderMetaText(order))
|
||||
.font(AppTypography.body)
|
||||
.minimumScaleFactor(0.01)
|
||||
.foregroundStyle(AppColors.textMuted)
|
||||
.lineLimit(1)
|
||||
if let rating = storeRating(for: order) {
|
||||
Text("•")
|
||||
.font(AppTypography.body)
|
||||
.minimumScaleFactor(0.01)
|
||||
.foregroundStyle(AppColors.textMuted)
|
||||
Image(systemName: "star.fill")
|
||||
.font(.system(size: 11, weight: .bold))
|
||||
.minimumScaleFactor(0.01)
|
||||
.foregroundStyle(Color(hex: "#7CF02A"))
|
||||
Text(String(format: "%.1f", rating).replacingOccurrences(of: ".", with: ","))
|
||||
.font(AppTypography.body)
|
||||
.minimumScaleFactor(0.01)
|
||||
.foregroundStyle(AppColors.textMuted)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
HStack(spacing: 12) {
|
||||
Button(status.isCanceled ? "Ajuda" : "Ver Detalhes") {
|
||||
selectedOrderRoute = detailsRoute
|
||||
}
|
||||
.font(AppTypography.heading3)
|
||||
.foregroundStyle(AppColors.textMuted)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.5)
|
||||
.buttonStyle(.plain)
|
||||
.appLayoutPriority(0)
|
||||
|
||||
Spacer(minLength: 8)
|
||||
|
||||
Button {
|
||||
if status.isInProgress {
|
||||
selectedOrderRoute = trackingRoute
|
||||
return
|
||||
}
|
||||
SnackbarCenter.shared.show(
|
||||
title: "Recompra será integrada com o catálogo em breve.",
|
||||
style: .info,
|
||||
icon: "cart.badge.plus",
|
||||
duration: 2.0
|
||||
)
|
||||
} label: {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: status.isInProgress ? "truck.box.fill" : "arrow.clockwise")
|
||||
Text(status.isInProgress ? "Acompanhar" : "Pedir Novamente")
|
||||
.font(AppTypography.heading3)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.5)
|
||||
}
|
||||
.lineLimit(1)
|
||||
//.frame(minWidth: status.isInProgress ? 136 : 184)
|
||||
.foregroundStyle(status.actionForeground)
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 11)
|
||||
.background(status.actionBackground)
|
||||
.clipShape(Capsule())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.appLayoutPriority(2)
|
||||
}
|
||||
}
|
||||
.padding(18)
|
||||
.background(AppColors.surface)
|
||||
.overlay(alignment: .leading) {
|
||||
if status.isInProgress {
|
||||
RoundedRectangle(cornerRadius: 3, style: .continuous)
|
||||
.fill(Color(hex: "#C8F06E"))
|
||||
.frame(width: 5)
|
||||
.padding(.vertical, 20)
|
||||
}
|
||||
}
|
||||
.clipShape(RoundedRectangle(cornerRadius: 28, style: .continuous))
|
||||
}
|
||||
|
||||
private func orderVisualStatus(for order: AppOrderSummary) -> OrderRowStatusStyle {
|
||||
let rawDetailed = (order.statusDetailed ?? "").trimmingCharacters(in: .whitespacesAndNewlines).uppercased()
|
||||
let rawStatus = (order.status ?? "").trimmingCharacters(in: .whitespacesAndNewlines).uppercased()
|
||||
|
||||
let technical = [rawDetailed, rawStatus].joined(separator: "|")
|
||||
if technical.contains("CANCEL") || technical.contains("REFUND") {
|
||||
return .canceled
|
||||
}
|
||||
if technical.contains("COMPLETED") || technical.contains("DELIVERED") {
|
||||
return .delivered
|
||||
}
|
||||
if technical.contains("IN_DELIVERY")
|
||||
|| technical.contains("DELIVERING")
|
||||
|| technical.contains("OUT_FOR_DELIVERY")
|
||||
|| technical.contains("PENDING")
|
||||
|| technical.contains("ACCEPTED")
|
||||
|| technical.contains("PREPAR")
|
||||
|| technical.contains("READY") {
|
||||
return .inProgress
|
||||
}
|
||||
|
||||
let label = (order.statusLabel ?? "").trimmingCharacters(in: .whitespacesAndNewlines).uppercased()
|
||||
if label.contains("CANCEL") {
|
||||
return .canceled
|
||||
}
|
||||
if label.contains("CONCLU") || label.contains("ENTREGUE") {
|
||||
return .delivered
|
||||
}
|
||||
return .inProgress
|
||||
}
|
||||
|
||||
private func formatCurrency(_ value: Double) -> String {
|
||||
String(format: "R$ %.2f", value).replacingOccurrences(of: ".", with: ",")
|
||||
}
|
||||
|
||||
private func orderMetaText(_ order: AppOrderSummary) -> String {
|
||||
let dateText = formatOrderDate(order.createdAt) ?? "Agora"
|
||||
let totalText = formatCurrency(order.total ?? 0)
|
||||
return "\(dateText) • \(totalText)"
|
||||
}
|
||||
|
||||
private func formatOrderDate(_ isoValue: String?) -> String? {
|
||||
guard let isoValue, isoValue.isEmpty == false else { return nil }
|
||||
let iso = ISO8601DateFormatter()
|
||||
iso.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
||||
var date = iso.date(from: isoValue)
|
||||
if date == nil {
|
||||
iso.formatOptions = [.withInternetDateTime]
|
||||
date = iso.date(from: isoValue)
|
||||
}
|
||||
guard let date else { return nil }
|
||||
|
||||
let formatter = DateFormatter()
|
||||
formatter.locale = Locale(identifier: "pt_BR")
|
||||
formatter.dateFormat = "dd MMM, HH:mm"
|
||||
return formatter.string(from: date)
|
||||
}
|
||||
|
||||
private func trackingOrderId(for order: AppOrderSummary) -> String {
|
||||
let orderCandidate = (order.orderId ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if orderCandidate.isEmpty == false {
|
||||
return orderCandidate
|
||||
}
|
||||
let candidate = (order.realId ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if candidate.isEmpty == false {
|
||||
return candidate
|
||||
}
|
||||
return order.id
|
||||
}
|
||||
|
||||
private func normalizedOrderId(_ value: String?) -> String {
|
||||
(value ?? "").trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
}
|
||||
|
||||
private func normalizedStoreName(_ value: String?) -> String {
|
||||
(value ?? "")
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
.folding(options: [.diacriticInsensitive, .caseInsensitive], locale: .current)
|
||||
.lowercased()
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func loadOrdersIfNeeded() async {
|
||||
guard hasLoadedOnce == false else { return }
|
||||
await loadOrders(force: false)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func loadOrders(force: Bool) async {
|
||||
if isLoading { return }
|
||||
if force == false, hasLoadedOnce { return }
|
||||
|
||||
isLoading = true
|
||||
errorMessage = nil
|
||||
let previousOrders = orders
|
||||
|
||||
var trackedMapped: [AppOrderSummary] = []
|
||||
let cachedTracked = SessionStateStore.loadTrackedOrders()
|
||||
if cachedTracked.isEmpty == false {
|
||||
trackedMapped = cachedTracked.map {
|
||||
AppOrderSummary.fromTracked($0)
|
||||
}
|
||||
if hasLoadedOnce == false, previousOrders.isEmpty {
|
||||
orders = mergeOrders(apiOrders: [], trackedOrders: trackedMapped)
|
||||
}
|
||||
}
|
||||
|
||||
do {
|
||||
let response = try await ApiService().listOrders(forceRefresh: force)
|
||||
if response.error {
|
||||
if previousOrders.isEmpty == false {
|
||||
orders = previousOrders
|
||||
}
|
||||
errorMessage = response.message ?? "Não foi possível carregar os pedidos."
|
||||
} else {
|
||||
let remote = response.result ?? []
|
||||
orders = mergeOrders(apiOrders: remote, trackedOrders: trackedMapped)
|
||||
}
|
||||
} catch {
|
||||
if isCancelledRequest(error) {
|
||||
if previousOrders.isEmpty == false {
|
||||
orders = previousOrders
|
||||
}
|
||||
isLoading = false
|
||||
return
|
||||
}
|
||||
if orders.isEmpty {
|
||||
errorMessage = "Não foi possível carregar os pedidos."
|
||||
}
|
||||
}
|
||||
|
||||
isLoading = false
|
||||
hasLoadedOnce = true
|
||||
}
|
||||
|
||||
private func mergeOrders(apiOrders: [AppOrderSummary], trackedOrders: [AppOrderSummary]) -> [AppOrderSummary] {
|
||||
var map: [String: AppOrderSummary] = [:]
|
||||
var sourceRank: [String: Int] = [:]
|
||||
|
||||
for (index, item) in trackedOrders.enumerated() {
|
||||
let key = identityKey(for: item)
|
||||
map[key] = item
|
||||
if sourceRank[key] == nil {
|
||||
sourceRank[key] = 10_000 + index
|
||||
}
|
||||
}
|
||||
|
||||
for (index, item) in apiOrders.enumerated() {
|
||||
let key = identityKey(for: item)
|
||||
map[key] = item
|
||||
sourceRank[key] = index
|
||||
}
|
||||
|
||||
return map.values.sorted { lhs, rhs in
|
||||
let leftDate = orderDateSortValue(lhs)
|
||||
let rightDate = orderDateSortValue(rhs)
|
||||
if leftDate != rightDate {
|
||||
return leftDate > rightDate
|
||||
}
|
||||
|
||||
let leftRank = sourceRank[identityKey(for: lhs)] ?? Int.max
|
||||
let rightRank = sourceRank[identityKey(for: rhs)] ?? Int.max
|
||||
if leftRank != rightRank {
|
||||
return leftRank < rightRank
|
||||
}
|
||||
|
||||
let leftNumericId = Int(lhs.id)
|
||||
let rightNumericId = Int(rhs.id)
|
||||
if let leftNumericId, let rightNumericId, leftNumericId != rightNumericId {
|
||||
return leftNumericId > rightNumericId
|
||||
}
|
||||
return lhs.id.localizedCompare(rhs.id) == .orderedDescending
|
||||
}
|
||||
}
|
||||
|
||||
private func identityKey(for order: AppOrderSummary) -> String {
|
||||
let raw = trackingOrderId(for: order)
|
||||
return raw.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
}
|
||||
|
||||
private func orderDateSortValue(_ order: AppOrderSummary) -> Date {
|
||||
parseDateForSort(order.updatedAt)
|
||||
?? parseDateForSort(order.createdAt)
|
||||
?? .distantPast
|
||||
}
|
||||
|
||||
private func parseDateForSort(_ rawValue: String?) -> Date? {
|
||||
guard let rawValue else { return nil }
|
||||
let value = rawValue.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if value.isEmpty { return nil }
|
||||
|
||||
let iso = ISO8601DateFormatter()
|
||||
iso.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
||||
if let date = iso.date(from: value) { return date }
|
||||
iso.formatOptions = [.withInternetDateTime]
|
||||
if let date = iso.date(from: value) { return date }
|
||||
|
||||
let formatter = DateFormatter()
|
||||
formatter.locale = Locale(identifier: "pt_BR")
|
||||
|
||||
let formats = [
|
||||
"yyyy-MM-dd'T'HH:mm:ss.SSSXXXXX",
|
||||
"yyyy-MM-dd'T'HH:mm:ssXXXXX",
|
||||
"yyyy-MM-dd HH:mm:ss",
|
||||
"yyyy-MM-dd'T'HH:mm:ss",
|
||||
"yyyy-MM-dd'T'HH:mm:ss.SSSZ",
|
||||
"yyyy-MM-dd HH:mm:ss Z",
|
||||
"dd/MM/yyyy HH:mm:ss",
|
||||
"dd/MM/yyyy HH:mm",
|
||||
"dd/MM/yyyy"
|
||||
]
|
||||
for format in formats {
|
||||
formatter.dateFormat = format
|
||||
if let date = formatter.date(from: value) {
|
||||
return date
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
private func isCancelledRequest(_ error: Error) -> Bool {
|
||||
if error is CancellationError {
|
||||
return true
|
||||
}
|
||||
|
||||
if let networkError = error as? NetworkError,
|
||||
case .transportError(let message) = networkError {
|
||||
let normalized = message.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
return normalized.contains("cancel")
|
||||
}
|
||||
|
||||
if let networkError = error as? NetworkError,
|
||||
case .cancelled = networkError {
|
||||
return true
|
||||
}
|
||||
|
||||
return error.localizedDescription.lowercased().contains("cancel")
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func refreshStoreRatings() async {
|
||||
var storeList: [StoreSummary] = AppContentCache.shared.value(
|
||||
for: AppCacheKey.homeStoresLatestSnapshot,
|
||||
as: [StoreSummary].self
|
||||
) ?? []
|
||||
|
||||
if storeList.isEmpty {
|
||||
let response = try? await ApiService().listStores()
|
||||
storeList = response?.result ?? []
|
||||
}
|
||||
|
||||
var byId: [String: Double] = [:]
|
||||
var byName: [String: Double] = [:]
|
||||
var logoById: [String: String] = [:]
|
||||
var logoByName: [String: String] = [:]
|
||||
for store in storeList {
|
||||
let storeId = normalizedOrderId(store.id)
|
||||
let nameKey = normalizedStoreName(store.name)
|
||||
if let rating = store.rating, rating > 0 {
|
||||
if storeId.isEmpty == false { byId[storeId] = rating }
|
||||
if nameKey.isEmpty == false { byName[nameKey] = rating }
|
||||
}
|
||||
if let logo = store.logo, logo.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false {
|
||||
if storeId.isEmpty == false { logoById[storeId] = logo }
|
||||
if nameKey.isEmpty == false { logoByName[nameKey] = logo }
|
||||
}
|
||||
}
|
||||
|
||||
storeRatingByStoreId = byId
|
||||
storeRatingByStoreName = byName
|
||||
storeLogoByStoreId = logoById
|
||||
storeLogoByStoreName = logoByName
|
||||
}
|
||||
|
||||
private func storeRating(for order: AppOrderSummary) -> Double? {
|
||||
let storeId = normalizedOrderId(order.storeId)
|
||||
if storeId.isEmpty == false, let fromId = storeRatingByStoreId[storeId] {
|
||||
return fromId
|
||||
}
|
||||
let nameKey = normalizedStoreName(order.storeName)
|
||||
if nameKey.isEmpty == false, let fromName = storeRatingByStoreName[nameKey] {
|
||||
return fromName
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private func storeLogoURL(for order: AppOrderSummary) -> String? {
|
||||
if let url = order.storeLogoURL, url.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false {
|
||||
return url
|
||||
}
|
||||
let storeId = normalizedOrderId(order.storeId)
|
||||
if storeId.isEmpty == false, let logo = storeLogoByStoreId[storeId] { return logo }
|
||||
let nameKey = normalizedStoreName(order.storeName)
|
||||
if nameKey.isEmpty == false, let logo = storeLogoByStoreName[nameKey] { return logo }
|
||||
return nil
|
||||
}
|
||||
|
||||
private func resolvedMediaURL(_ raw: String?) -> String? {
|
||||
ImageSourceResolver.resolve(raw)
|
||||
}
|
||||
}
|
||||
|
||||
struct OrderRouteContext: Identifiable, Hashable {
|
||||
var id: String { "\(orderId)|\(intent.rawValue)" }
|
||||
let orderId: String
|
||||
let shortId: String?
|
||||
let paymentMethod: String?
|
||||
let total: Double?
|
||||
let intent: OrderRouteIntent
|
||||
}
|
||||
|
||||
enum OrderRouteIntent: String, Hashable {
|
||||
case details
|
||||
case tracking
|
||||
case auto
|
||||
}
|
||||
|
||||
private enum OrderRowStatusStyle {
|
||||
case delivered
|
||||
case inProgress
|
||||
case canceled
|
||||
|
||||
var badgeTitle: String {
|
||||
switch self {
|
||||
case .delivered: return "Entregue"
|
||||
case .inProgress: return "Em andamento"
|
||||
case .canceled: return "Cancelado"
|
||||
}
|
||||
}
|
||||
|
||||
var badgeForeground: Color {
|
||||
switch self {
|
||||
case .delivered: return Color(hex: "#16843B")
|
||||
case .inProgress: return Color(hex: "#B06A28")
|
||||
case .canceled: return Color(hex: "#D62828")
|
||||
}
|
||||
}
|
||||
|
||||
var badgeBackground: Color {
|
||||
switch self {
|
||||
case .delivered: return Color(hex: "#E8F7E9")
|
||||
case .inProgress: return Color(hex: "#FFF2E5")
|
||||
case .canceled: return Color(hex: "#FDECEC")
|
||||
}
|
||||
}
|
||||
|
||||
var actionForeground: Color {
|
||||
switch self {
|
||||
case .inProgress: return .white
|
||||
case .delivered, .canceled: return Color(hex: "#0E1A06")
|
||||
}
|
||||
}
|
||||
|
||||
var actionBackground: Color {
|
||||
switch self {
|
||||
case .inProgress: return Color(hex: "#111216")
|
||||
case .delivered, .canceled: return Color(hex: "#C8F06E")
|
||||
}
|
||||
}
|
||||
|
||||
var isInProgress: Bool { self == .inProgress }
|
||||
var isCanceled: Bool { self == .canceled }
|
||||
}
|
||||
extension AppOrderSummary {
|
||||
static func fromTracked(_ tracked: PublicOrderResult) -> AppOrderSummary {
|
||||
AppOrderSummary(
|
||||
id: tracked.id,
|
||||
orderId: tracked.realId ?? tracked.id,
|
||||
realId: tracked.realId,
|
||||
storeId: nil,
|
||||
shortId: tracked.shortId,
|
||||
total: tracked.total,
|
||||
status: tracked.status,
|
||||
statusDetailed: nil,
|
||||
statusLabel: nil,
|
||||
nextAction: nil,
|
||||
paymentStatus: tracked.paymentStatus,
|
||||
paymentMethod: tracked.paymentMethod,
|
||||
deliveryType: tracked.deliveryType,
|
||||
storeName: tracked.storeName,
|
||||
storePhone: tracked.storePhone,
|
||||
storeLogoURL: tracked.storeLogoURL,
|
||||
createdAt: tracked.createdAt,
|
||||
updatedAt: tracked.updatedAt
|
||||
)
|
||||
}
|
||||
|
||||
init(
|
||||
id: String,
|
||||
orderId: String?,
|
||||
realId: String?,
|
||||
storeId: String?,
|
||||
shortId: String?,
|
||||
total: Double?,
|
||||
status: String?,
|
||||
statusDetailed: String?,
|
||||
statusLabel: String?,
|
||||
nextAction: String?,
|
||||
paymentStatus: String?,
|
||||
paymentMethod: String?,
|
||||
deliveryType: String?,
|
||||
storeName: String?,
|
||||
storePhone: String?,
|
||||
storeLogoURL: String?,
|
||||
createdAt: String?,
|
||||
updatedAt: String?
|
||||
) {
|
||||
self.id = id
|
||||
self.orderId = orderId
|
||||
self.realId = realId
|
||||
self.storeId = storeId
|
||||
self.shortId = shortId
|
||||
self.total = total
|
||||
self.status = status
|
||||
self.statusDetailed = statusDetailed
|
||||
self.statusLabel = statusLabel
|
||||
self.nextAction = nextAction
|
||||
self.paymentStatus = paymentStatus
|
||||
self.paymentMethod = paymentMethod
|
||||
self.deliveryType = deliveryType
|
||||
self.storeName = storeName
|
||||
self.storePhone = storePhone
|
||||
self.storeLogoURL = storeLogoURL
|
||||
self.createdAt = createdAt
|
||||
self.updatedAt = updatedAt
|
||||
}
|
||||
}
|
||||
|
||||
struct OrderEntryDestinationView: View {
|
||||
let orderId: String
|
||||
let initialShortId: String?
|
||||
let fallbackPaymentMethod: String?
|
||||
let fallbackTotal: Double?
|
||||
let routeIntent: OrderRouteIntent
|
||||
@Binding var appState: AppState
|
||||
|
||||
@State var isResolvingRoute = true
|
||||
@State var didResolve = false
|
||||
@State var pixContext: PixPaymentContext? = nil
|
||||
@State var orderTrackingContext: OrderTrackingContext? = nil
|
||||
@State var orderDetails: PublicOrderResult? = nil
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if isResolvingRoute {
|
||||
VStack(spacing: 10) {
|
||||
ProgressView()
|
||||
Text("Carregando pedido...")
|
||||
.font(AppTypography.body)
|
||||
.foregroundStyle(AppColors.textMuted)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.background(AppColors.backgroundLight)
|
||||
} else if let pixContext {
|
||||
PaymentPixView(
|
||||
context: pixContext,
|
||||
appState: $appState,
|
||||
onPaymentConfirmed: {
|
||||
orderTrackingContext = OrderTrackingContext(orderId: pixContext.orderId, shortId: pixContext.shortId)
|
||||
},
|
||||
onOpenTracking: {
|
||||
orderTrackingContext = OrderTrackingContext(orderId: pixContext.orderId, shortId: pixContext.shortId)
|
||||
}
|
||||
)
|
||||
} else if let orderDetails {
|
||||
OrderDetailsView(order: orderDetails, orderId: orderId, initialShortId: initialShortId, appState: $appState)
|
||||
} else {
|
||||
OrderTrackingView(orderId: orderId, initialShortId: initialShortId)
|
||||
}
|
||||
}
|
||||
.navigationDestination(item: $orderTrackingContext) { context in
|
||||
OrderTrackingView(orderId: context.orderId, initialShortId: context.shortId)
|
||||
}
|
||||
.task {
|
||||
guard didResolve == false else { return }
|
||||
didResolve = true
|
||||
await resolveRoute()
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func resolveRoute() async {
|
||||
defer { isResolvingRoute = false }
|
||||
|
||||
let order = await fetchOrderForRouting()
|
||||
guard let order else { return }
|
||||
|
||||
if routeIntent == .details {
|
||||
orderDetails = order
|
||||
return
|
||||
}
|
||||
|
||||
// For both .tracking and .auto: show payment screen if payment is still pending.
|
||||
// Timeline only shows once payment is confirmed or method is off-app.
|
||||
if shouldOpenPaymentScreen(for: order) {
|
||||
let normalizedMethod = normalizePaymentMethod(order)
|
||||
if normalizedMethod.contains("PIX") {
|
||||
let pixFromPayment = order.payment?.pix
|
||||
let pixFromPayload = order.paymentPayload
|
||||
let copyPaste = (pixFromPayment?.copyPaste?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false)
|
||||
? pixFromPayment?.copyPaste
|
||||
: pixFromPayload?.copyPaste
|
||||
let qrCodeImage = (pixFromPayment?.qrCodeImage?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false)
|
||||
? pixFromPayment?.qrCodeImage
|
||||
: pixFromPayload?.qrCodeImage
|
||||
let expirationDate = (pixFromPayment?.expirationDate?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false)
|
||||
? pixFromPayment?.expirationDate
|
||||
: pixFromPayload?.expirationDate
|
||||
let storeId = order.storeId ?? ""
|
||||
pixContext = PixPaymentContext(
|
||||
id: order.id,
|
||||
orderId: order.id,
|
||||
shortId: order.shortId ?? initialShortId,
|
||||
storeId: storeId,
|
||||
copyPaste: copyPaste?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false
|
||||
? (copyPaste ?? "")
|
||||
: "Código PIX indisponível no momento. Aguarde e tente novamente.",
|
||||
qrCodeImageBase64: qrCodeImage,
|
||||
expirationDate: expirationDate,
|
||||
total: order.total ?? 0,
|
||||
profileName: "",
|
||||
profileEmail: "",
|
||||
profilePhone: "",
|
||||
addressZip: nil,
|
||||
addressNumber: nil,
|
||||
deliveryType: order.deliveryType ?? "DELIVERY",
|
||||
itemsJSON: "[]"
|
||||
)
|
||||
return
|
||||
}
|
||||
if normalizedMethod == "CREDIT_CARD" || normalizedMethod == "DEBIT_CARD" {
|
||||
orderTrackingContext = OrderTrackingContext(orderId: order.id, shortId: order.shortId ?? initialShortId)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// For .auto only: route terminal/canceled orders to details instead of timeline.
|
||||
if routeIntent == .auto && shouldOpenOrderDetails(for: order) {
|
||||
orderDetails = order
|
||||
}
|
||||
// .tracking (and .auto fallthrough) → nil states → body renders OrderTrackingView
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func fetchOrderForRouting() async -> PublicOrderResult? {
|
||||
logger.info("OrderEntry fetch route orderId=\(orderId)")
|
||||
do {
|
||||
let response = try await ApiService().publicOrder(orderId: orderId)
|
||||
if response.error == false, let result = response.result {
|
||||
logger.info("OrderEntry fetch route success orderId=\(orderId) status=\(result.status ?? "nil") paymentStatus=\(result.paymentStatus ?? "nil")")
|
||||
return result
|
||||
}
|
||||
logger.error("OrderEntry fetch route API error orderId=\(orderId) message=\(response.message ?? "unknown")")
|
||||
} catch {
|
||||
logger.error("OrderEntry fetch route failure orderId=\(orderId) error=\(error.localizedDescription)")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func isOnlinePaymentMethod(_ order: PublicOrderResult) -> Bool {
|
||||
let method = normalizePaymentMethod(order)
|
||||
return method == "PIX" || method == "CREDIT_CARD" || method == "DEBIT_CARD"
|
||||
}
|
||||
|
||||
func shouldOpenOrderDetails(for order: PublicOrderResult) -> Bool {
|
||||
let status = normalize(order.status)
|
||||
if status.contains("CANCEL") { return true }
|
||||
if status.contains("COMPLETED") || status.contains("DELIVERED") { return true }
|
||||
return false
|
||||
}
|
||||
|
||||
func shouldOpenPaymentScreen(for order: PublicOrderResult) -> Bool {
|
||||
guard order.isPaymentConfirmed == false else { return false }
|
||||
guard isOnlinePaymentMethod(order) else { return false }
|
||||
guard isPaymentPending(order) else { return false }
|
||||
guard isInStorePayment(order) == false else { return false }
|
||||
|
||||
let status = normalize(order.status)
|
||||
if status.contains("PREPAR") ||
|
||||
status.contains("READY") ||
|
||||
status.contains("DELIVER") ||
|
||||
status.contains("ROTA") ||
|
||||
status.contains("COMPLETED") ||
|
||||
status.contains("CANCEL") ||
|
||||
status.contains("REFUND") {
|
||||
return false
|
||||
}
|
||||
|
||||
let paymentStatus = normalize(order.paymentStatus)
|
||||
if paymentStatus.contains("CONFIRM") ||
|
||||
paymentStatus.contains("PAID") ||
|
||||
paymentStatus.contains("RECEIV") ||
|
||||
paymentStatus.contains("APPROV") {
|
||||
return false
|
||||
}
|
||||
|
||||
let method = normalizePaymentMethod(order)
|
||||
if method == "PIX" {
|
||||
return hasPixPayload(order)
|
||||
}
|
||||
return method == "CREDIT_CARD" || method == "DEBIT_CARD"
|
||||
}
|
||||
|
||||
func hasPixPayload(_ order: PublicOrderResult) -> Bool {
|
||||
let fromPayment = (order.payment?.pix?.copyPaste ?? "")
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if fromPayment.isEmpty == false { return true }
|
||||
|
||||
let fromPayload = (order.paymentPayload?.copyPaste ?? "")
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return fromPayload.isEmpty == false
|
||||
}
|
||||
|
||||
func isPaymentPending(_ order: PublicOrderResult) -> Bool {
|
||||
let status = normalize(order.status)
|
||||
let paymentStatus = normalize(order.paymentStatus)
|
||||
let nextAction = normalize(order.nextAction)
|
||||
|
||||
if status.contains("PAYMENT_PENDING") {
|
||||
return true
|
||||
}
|
||||
if paymentStatus.contains("PENDING") {
|
||||
return true
|
||||
}
|
||||
if nextAction.contains("PAY") || nextAction.contains("PAYMENT") {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func isInStorePayment(_ order: PublicOrderResult) -> Bool {
|
||||
let nextAction = normalize(order.nextAction)
|
||||
if nextAction.contains("TRACK") || nextAction.contains("DELIVER") {
|
||||
return true
|
||||
}
|
||||
|
||||
let status = normalize(order.status)
|
||||
if status.contains("PREPAR") ||
|
||||
status.contains("READY") ||
|
||||
status.contains("DELIVER") ||
|
||||
status.contains("ROTA") ||
|
||||
status.contains("OUT_FOR_DELIVERY") {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func normalizePaymentMethod(_ order: PublicOrderResult) -> String {
|
||||
let first = normalize(order.paymentMethodCode)
|
||||
if first.isEmpty == false {
|
||||
return first
|
||||
}
|
||||
let second = normalize(order.paymentMethod)
|
||||
if second.isEmpty == false {
|
||||
return second
|
||||
}
|
||||
return normalize(fallbackPaymentMethod)
|
||||
}
|
||||
|
||||
func normalize(_ value: String?) -> String {
|
||||
(value ?? "")
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
.uppercased()
|
||||
}
|
||||
}
|
||||
122
Sources/PediFoods/Views/Main/PizzaFlavorAddonsSheet.swift
Normal file
@@ -0,0 +1,122 @@
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
struct PizzaFlavorAddonsSheet: View {
|
||||
let flavor: StoreCatalogProduct
|
||||
@Binding var quantities: [String: Int]
|
||||
|
||||
@Environment(\.dismiss) var dismiss
|
||||
|
||||
var body: some View {
|
||||
ScrollView(showsIndicators: false) {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
screenHeader
|
||||
Text(flavor.name)
|
||||
.font(AppTypography.heading2)
|
||||
.foregroundStyle(AppColors.textPrimary)
|
||||
|
||||
if flavor.addonGroups.isEmpty {
|
||||
Text("Este sabor não possui adicionais.")
|
||||
.font(AppTypography.body)
|
||||
.foregroundStyle(AppColors.textMuted)
|
||||
} else {
|
||||
ForEach(flavor.addonGroups) { group in
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Text(group.name)
|
||||
.font(AppTypography.heading3)
|
||||
.foregroundStyle(AppColors.textPrimary)
|
||||
|
||||
ForEach(group.items) { item in
|
||||
HStack(spacing: 10) {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(item.name)
|
||||
.font(AppTypography.body)
|
||||
.foregroundStyle(AppColors.textPrimary)
|
||||
Text("+ \(formatCurrency(item.price ?? 0))")
|
||||
.font(.caption)
|
||||
.foregroundStyle(AppColors.textMuted)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
HStack(spacing: 8) {
|
||||
Button(action: { decrement(item.id) }) {
|
||||
Image(systemName: "minus")
|
||||
.font(.system(size: 12, weight: .bold))
|
||||
.foregroundStyle(AppColors.textPrimary)
|
||||
.frame(width: 26, height: 26)
|
||||
.background(AppColors.brandSoft)
|
||||
.clipShape(Circle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled((quantities[item.id] ?? 0) <= 0)
|
||||
|
||||
Text("\(quantities[item.id] ?? 0)")
|
||||
.font(AppTypography.heading3)
|
||||
.foregroundStyle(AppColors.textPrimary)
|
||||
.frame(minWidth: 18)
|
||||
|
||||
Button(action: { increment(item.id) }) {
|
||||
Image(systemName: "plus")
|
||||
.font(.system(size: 12, weight: .bold))
|
||||
.foregroundStyle(AppColors.textPrimary)
|
||||
.frame(width: 26, height: 26)
|
||||
.background(AppColors.tertiary)
|
||||
.clipShape(Circle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(12)
|
||||
.background(AppColors.surface)
|
||||
.clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous))
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(20)
|
||||
}
|
||||
.background(AppColors.backgroundLight.ignoresSafeArea())
|
||||
.appHiddenNavigationBar()
|
||||
.navigationBarBackButtonHidden(true)
|
||||
}
|
||||
|
||||
private var screenHeader: some View {
|
||||
ZStack {
|
||||
Text("Adicionais")
|
||||
.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 func increment(_ addonId: String) {
|
||||
quantities[addonId, default: 0] += 1
|
||||
}
|
||||
|
||||
private func decrement(_ addonId: String) {
|
||||
let current = quantities[addonId] ?? 0
|
||||
if current <= 1 {
|
||||
quantities.removeValue(forKey: addonId)
|
||||
} else {
|
||||
quantities[addonId] = current - 1
|
||||
}
|
||||
}
|
||||
|
||||
private func formatCurrency(_ value: Double) -> String {
|
||||
String(format: "R$ %.2f", value).replacingOccurrences(of: ".", with: ",")
|
||||
}
|
||||
}
|
||||
293
Sources/PediFoods/Views/Main/PizzaProductDetailSheet+Flow.swift
Normal file
@@ -0,0 +1,293 @@
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
extension PizzaProductDetailSheet {
|
||||
|
||||
// MARK: - Steps
|
||||
|
||||
var stepSizes: some View {
|
||||
accordionSection(
|
||||
step: 0,
|
||||
label: "Tamanho",
|
||||
summary: selectedSize.map { "\($0.name ?? "") • Até \(max(1, $0.maxFlavors ?? 1)) sabor(es)" }
|
||||
) {
|
||||
ForEach(sizes) { size in
|
||||
radioRow(
|
||||
title: size.name ?? "Tamanho",
|
||||
subtitle: "Até \(max(1, size.maxFlavors ?? 1)) sabor(es)",
|
||||
isSelected: selectedSizeId == size.id
|
||||
) {
|
||||
selectedSizeId = size.id
|
||||
applyAutoSelections()
|
||||
withAnimation(.easeInOut(duration: 0.2)) {
|
||||
expandedStep = nextStep(after: 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var stepDoughs: some View {
|
||||
accordionSection(
|
||||
step: 1,
|
||||
label: "Massa",
|
||||
summary: doughs.count <= 1
|
||||
? (doughs.first?.name ?? "Tradicional")
|
||||
: doughs.first(where: { $0.id == selectedDoughId })?.name
|
||||
) {
|
||||
if doughs.count <= 1 {
|
||||
Text(doughs.first?.name ?? "Massa tradicional")
|
||||
.font(AppTypography.body)
|
||||
.foregroundStyle(AppColors.textPrimary)
|
||||
} else {
|
||||
ForEach(doughs) { dough in
|
||||
radioRow(
|
||||
title: dough.name ?? "Massa",
|
||||
subtitle: nil,
|
||||
isSelected: selectedDoughId == dough.id
|
||||
) {
|
||||
selectedDoughId = dough.id
|
||||
applyAutoSelections()
|
||||
withAnimation(.easeInOut(duration: 0.2)) {
|
||||
expandedStep = nextStep(after: 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var stepCrusts: some View {
|
||||
accordionSection(
|
||||
step: 2,
|
||||
label: "Borda",
|
||||
summary: crusts.count <= 1
|
||||
? crustDescription(crusts.first)
|
||||
: crusts.first(where: { $0.id == selectedCrustId }).map { crustDescription($0) }
|
||||
) {
|
||||
if crusts.count <= 1 {
|
||||
Text(crustDescription(crusts.first))
|
||||
.font(AppTypography.body)
|
||||
.foregroundStyle(AppColors.textPrimary)
|
||||
} else {
|
||||
ForEach(crusts) { crust in
|
||||
radioRow(
|
||||
title: crust.name ?? "Borda",
|
||||
subtitle: (crust.priceModifier ?? 0) > 0 ? "+ \(formatCurrency(crust.priceModifier ?? 0))" : nil,
|
||||
isSelected: selectedCrustId == crust.id
|
||||
) {
|
||||
selectedCrustId = crust.id
|
||||
withAnimation(.easeInOut(duration: 0.2)) {
|
||||
expandedStep = 3
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var stepFlavors: some View {
|
||||
accordionSection(
|
||||
step: 3,
|
||||
label: "Sabores (\(selectedFlavorIds.count)/\(maxFlavorsAllowed))",
|
||||
summary: selectedFlavorIds.isEmpty ? nil
|
||||
: selectedFlavorProducts.map(\.name).joined(separator: ", ")
|
||||
) {
|
||||
Text("Toque no sabor para escolher adicionais.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(AppColors.textMuted)
|
||||
|
||||
ForEach(flavors) { flavor in
|
||||
let isSelected = selectedFlavorIds.contains(flavor.id)
|
||||
let price = selectedSizeId.flatMap { flavor.pizzaPrices[$0] } ?? flavor.price
|
||||
let maxReached = selectedFlavorIds.count >= maxFlavorsAllowed
|
||||
|
||||
HStack(spacing: 10) {
|
||||
AsyncStoreImage(imageURL: resolveImageURL(flavor.image))
|
||||
.frame(width: 52, height: 52)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous))
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(flavor.name)
|
||||
.font(AppTypography.body)
|
||||
.foregroundStyle(AppColors.textPrimary)
|
||||
if let price {
|
||||
Text(formatCurrency(price))
|
||||
.font(.caption)
|
||||
.foregroundStyle(AppColors.textMuted)
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
Toggle("", isOn: Binding(
|
||||
get: { isSelected },
|
||||
set: { value in
|
||||
if value { addFlavor(flavor.id) } else { removeFlavor(flavor.id) }
|
||||
}
|
||||
))
|
||||
.labelsHidden()
|
||||
.disabled(!isSelected && maxReached)
|
||||
}
|
||||
.padding(.vertical, 2)
|
||||
.appContentShape(Rectangle())
|
||||
.onTapGesture {
|
||||
guard isSelected else { return }
|
||||
guard flavor.addonGroups.contains(where: { $0.items.isEmpty == false }) else { return }
|
||||
selectedFlavorForAddons = flavor
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Accordion container
|
||||
|
||||
func accordionSection(
|
||||
step: Int,
|
||||
label: String,
|
||||
summary: String?,
|
||||
@ViewBuilder content: () -> some View
|
||||
) -> some View {
|
||||
let isExpanded = expandedStep == step
|
||||
let isDone = summary != nil
|
||||
|
||||
return VStack(spacing: 0) {
|
||||
Button {
|
||||
withAnimation(.easeInOut(duration: 0.2)) {
|
||||
expandedStep = isExpanded ? -1 : step
|
||||
}
|
||||
} label: {
|
||||
HStack(spacing: 10) {
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(isDone || isExpanded ? AppColors.primary : AppColors.textMuted.opacity(0.25))
|
||||
.frame(width: 26, height: 26)
|
||||
if isDone && !isExpanded {
|
||||
Image(systemName: "checkmark")
|
||||
.font(.system(size: 11, weight: .bold))
|
||||
.foregroundStyle(.white)
|
||||
} else {
|
||||
Text("\(step + 1)")
|
||||
.font(.system(size: 12, weight: .bold))
|
||||
.foregroundStyle(.white)
|
||||
}
|
||||
}
|
||||
|
||||
Text(label)
|
||||
.font(AppTypography.heading3)
|
||||
.foregroundStyle(AppColors.textPrimary)
|
||||
|
||||
Spacer()
|
||||
|
||||
if let summary, !isExpanded {
|
||||
Text(summary)
|
||||
.font(AppTypography.caption)
|
||||
.foregroundStyle(AppColors.textMuted)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
.frame(maxWidth: 140, alignment: .trailing)
|
||||
}
|
||||
|
||||
Image(systemName: isExpanded ? "chevron.up" : "chevron.down")
|
||||
.font(.system(size: 12, weight: .semibold))
|
||||
.foregroundStyle(AppColors.textMuted)
|
||||
}
|
||||
.padding(14)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
if isExpanded {
|
||||
Divider().padding(.horizontal, 14)
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
content()
|
||||
}
|
||||
.padding(14)
|
||||
.transition(.opacity.combined(with: .move(edge: .top)))
|
||||
}
|
||||
}
|
||||
.background(AppColors.surface)
|
||||
.clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous))
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
func nextStep(after step: Int) -> Int {
|
||||
if step == 0 {
|
||||
if doughs.count > 1 { return 1 }
|
||||
if crusts.count > 1 { return 2 }
|
||||
return 3
|
||||
}
|
||||
if step == 1 {
|
||||
if crusts.count > 1 { return 2 }
|
||||
return 3
|
||||
}
|
||||
return 3
|
||||
}
|
||||
|
||||
func radioRow(title: String, subtitle: String?, isSelected: Bool, action: @escaping () -> Void) -> some View {
|
||||
Button(action: action) {
|
||||
HStack(spacing: 10) {
|
||||
ZStack {
|
||||
Circle()
|
||||
.stroke(isSelected ? AppColors.primary : AppColors.textMuted.opacity(0.4), lineWidth: 2)
|
||||
.frame(width: 20, height: 20)
|
||||
if isSelected {
|
||||
Circle()
|
||||
.fill(AppColors.primary)
|
||||
.frame(width: 10, height: 10)
|
||||
}
|
||||
}
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(title)
|
||||
.font(AppTypography.body)
|
||||
.foregroundStyle(AppColors.textPrimary)
|
||||
if let subtitle, subtitle.isEmpty == false {
|
||||
Text(subtitle)
|
||||
.font(.caption)
|
||||
.foregroundStyle(AppColors.textMuted)
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
.appContentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
func crustDescription(_ crust: StorePizzaCrust?) -> String {
|
||||
guard let crust else { return "Sem borda especial" }
|
||||
let name = crust.name ?? "Borda"
|
||||
let modifier = crust.priceModifier ?? 0
|
||||
return modifier > 0 ? "\(name) (+ \(formatCurrency(modifier)))" : name
|
||||
}
|
||||
|
||||
func applyAutoSelections() {
|
||||
if selectedSizeId != nil {
|
||||
if doughs.count == 1 { selectedDoughId = doughs.first?.id }
|
||||
else if doughs.isEmpty { selectedDoughId = "__none__" }
|
||||
}
|
||||
if isDoughReady {
|
||||
if crusts.count == 1 { selectedCrustId = crusts.first?.id }
|
||||
else if crusts.isEmpty { selectedCrustId = "__none__" }
|
||||
}
|
||||
}
|
||||
|
||||
func trimFlavorSelectionByLimit() {
|
||||
let limit = maxFlavorsAllowed
|
||||
guard selectedFlavorIds.count > limit else { return }
|
||||
selectedFlavorIds = Set(selectedFlavorIds.sorted().prefix(limit))
|
||||
}
|
||||
|
||||
func addFlavor(_ flavorId: String) {
|
||||
guard !selectedFlavorIds.contains(flavorId),
|
||||
selectedFlavorIds.count < maxFlavorsAllowed else { return }
|
||||
selectedFlavorIds.insert(flavorId)
|
||||
}
|
||||
|
||||
func removeFlavor(_ flavorId: String) {
|
||||
selectedFlavorIds.remove(flavorId)
|
||||
flavorAddonQuantities.removeValue(forKey: flavorId)
|
||||
}
|
||||
|
||||
func formatCurrency(_ value: Double) -> String {
|
||||
String(format: "R$ %.2f", value).replacingOccurrences(of: ".", with: ",")
|
||||
}
|
||||
}
|
||||
364
Sources/PediFoods/Views/Main/PizzaProductDetailSheet.swift
Normal file
@@ -0,0 +1,364 @@
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
struct PizzaProductDetailSheet: View {
|
||||
let category: StoreCatalogCategory
|
||||
let storeId: String
|
||||
let resolveImageURL: (String?) -> String?
|
||||
let currentQuantityForItemId: (String) -> Int
|
||||
let onAdd: (CartItemState) -> Void
|
||||
|
||||
@Environment(\.dismiss) var dismiss
|
||||
|
||||
@State var selectedSizeId: String? = nil
|
||||
@State var selectedDoughId: String? = nil
|
||||
@State var selectedCrustId: String? = nil
|
||||
@State var selectedFlavorIds: Set<String> = []
|
||||
@State var flavorAddonQuantities: [String: [String: Int]] = [:]
|
||||
@State var selectedFlavorForAddons: StoreCatalogProduct? = nil
|
||||
@State var quantity: Int = 1
|
||||
@State var expandedStep: Int = 0
|
||||
|
||||
var flavors: [StoreCatalogProduct] {
|
||||
category.products
|
||||
}
|
||||
|
||||
var pizzaConfig: StorePizzaConfig? {
|
||||
category.pizzaConfig
|
||||
}
|
||||
|
||||
var sizes: [StorePizzaSize] {
|
||||
pizzaConfig?.sizes ?? []
|
||||
}
|
||||
|
||||
var doughs: [StorePizzaDough] {
|
||||
(pizzaConfig?.doughs ?? []).filter { $0.active ?? true }
|
||||
}
|
||||
|
||||
var crusts: [StorePizzaCrust] {
|
||||
(pizzaConfig?.crusts ?? []).filter { $0.active ?? true }
|
||||
}
|
||||
|
||||
private var representativeImage: String? {
|
||||
let firstImage = flavors
|
||||
.compactMap(\.image)
|
||||
.first { $0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false }
|
||||
return resolveImageURL(firstImage)
|
||||
}
|
||||
|
||||
var selectedSize: StorePizzaSize? {
|
||||
guard let selectedSizeId else { return nil }
|
||||
return sizes.first(where: { $0.id == selectedSizeId })
|
||||
}
|
||||
|
||||
private var selectedDoughName: String? {
|
||||
guard let selectedDoughId else { return nil }
|
||||
return doughs.first(where: { $0.id == selectedDoughId })?.name
|
||||
}
|
||||
|
||||
private var selectedCrust: StorePizzaCrust? {
|
||||
guard let selectedCrustId else { return nil }
|
||||
return crusts.first(where: { $0.id == selectedCrustId })
|
||||
}
|
||||
|
||||
var maxFlavorsAllowed: Int {
|
||||
max(1, selectedSize?.maxFlavors ?? 1)
|
||||
}
|
||||
|
||||
var isDoughReady: Bool {
|
||||
selectedSizeId != nil && (doughs.isEmpty || selectedDoughId != nil)
|
||||
}
|
||||
|
||||
var isCrustReady: Bool {
|
||||
isDoughReady && (crusts.isEmpty || selectedCrustId != nil)
|
||||
}
|
||||
|
||||
var canShowFlavors: Bool {
|
||||
isCrustReady
|
||||
}
|
||||
|
||||
var selectedFlavorProducts: [StoreCatalogProduct] {
|
||||
flavors
|
||||
.filter { selectedFlavorIds.contains($0.id) }
|
||||
.sorted { $0.name < $1.name }
|
||||
}
|
||||
|
||||
private var canConfirm: Bool {
|
||||
selectedSizeId != nil && isDoughReady && isCrustReady && selectedFlavorIds.isEmpty == false && quantity > 0
|
||||
}
|
||||
|
||||
private var crustPriceModifier: Double {
|
||||
selectedCrust?.priceModifier ?? 0
|
||||
}
|
||||
|
||||
private var addonsTotal: Double {
|
||||
selectedFlavorProducts.reduce(0) { partial, flavor in
|
||||
let byAddon = flavorAddonQuantities[flavor.id] ?? [:]
|
||||
let pricesByAddon = Dictionary(uniqueKeysWithValues: flavor.addonGroups.flatMap(\.items).map { ($0.id, $0.price ?? 0) })
|
||||
let subtotal = byAddon.reduce(0.0) { line, pair in
|
||||
line + (Double(max(0, pair.value)) * (pricesByAddon[pair.key] ?? 0))
|
||||
}
|
||||
return partial + subtotal
|
||||
}
|
||||
}
|
||||
|
||||
private var basePizzaPrice: Double {
|
||||
let prices = selectedFlavorProducts.map { flavor in
|
||||
guard let selectedSizeId else { return flavor.price ?? 0 }
|
||||
return flavor.pizzaPrices[selectedSizeId] ?? flavor.price ?? 0
|
||||
}
|
||||
guard prices.isEmpty == false else { return 0 }
|
||||
return prices.reduce(0, +) / Double(prices.count)
|
||||
}
|
||||
|
||||
private var unitPrice: Double {
|
||||
basePizzaPrice + crustPriceModifier + addonsTotal
|
||||
}
|
||||
|
||||
private var totalPrice: Double {
|
||||
unitPrice * Double(quantity)
|
||||
}
|
||||
|
||||
private var cartItemId: String {
|
||||
var tokens: [String] = []
|
||||
if let selectedSizeId { tokens.append("size:\(selectedSizeId)") }
|
||||
if let selectedDoughId { tokens.append("dough:\(selectedDoughId)") }
|
||||
if let selectedCrustId { tokens.append("crust:\(selectedCrustId)") }
|
||||
|
||||
let flavorsToken = selectedFlavorIds.sorted().joined(separator: ",")
|
||||
tokens.append("flavors:\(flavorsToken)")
|
||||
|
||||
let addonsToken = flavorAddonQuantities
|
||||
.flatMap { flavorId, addons in
|
||||
addons
|
||||
.filter { $0.value > 0 }
|
||||
.map { "\(flavorId):\($0.key):\($0.value)" }
|
||||
}
|
||||
.sorted()
|
||||
.joined(separator: ",")
|
||||
if addonsToken.isEmpty == false {
|
||||
tokens.append("addons:\(addonsToken)")
|
||||
}
|
||||
|
||||
return "\(storeId)::pizza::\(category.id)::" + tokens.joined(separator: "|")
|
||||
}
|
||||
|
||||
private var selectedAddonsPayload: [CartItemAddonState] {
|
||||
var payload: [CartItemAddonState] = []
|
||||
for flavor in selectedFlavorProducts {
|
||||
let byAddon = flavorAddonQuantities[flavor.id] ?? [:]
|
||||
let addonMap = Dictionary(uniqueKeysWithValues: flavor.addonGroups.flatMap(\.items).map { ($0.id, $0) })
|
||||
for (addonId, qty) in byAddon {
|
||||
guard qty > 0, let addon = addonMap[addonId] else { continue }
|
||||
payload.append(
|
||||
CartItemAddonState(
|
||||
id: "\(flavor.id)::\(addon.id)",
|
||||
name: "\(flavor.name) • \(addon.name)",
|
||||
quantity: qty,
|
||||
unitPrice: addon.price ?? 0
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
private var selectedDetailsText: String? {
|
||||
var chunks: [String] = []
|
||||
if let selectedSizeName = selectedSize?.name {
|
||||
chunks.append("Tamanho: \(selectedSizeName)")
|
||||
}
|
||||
if let selectedDoughName, selectedDoughName.isEmpty == false {
|
||||
chunks.append("Massa: \(selectedDoughName)")
|
||||
}
|
||||
if let crustName = selectedCrust?.name, crustName.isEmpty == false {
|
||||
chunks.append("Borda: \(crustName)")
|
||||
}
|
||||
if selectedFlavorProducts.isEmpty == false {
|
||||
chunks.append("Sabores: " + selectedFlavorProducts.map(\.name).joined(separator: ", "))
|
||||
}
|
||||
return chunks.isEmpty ? nil : chunks.joined(separator: " • ")
|
||||
}
|
||||
|
||||
private var pizzaChoices: [String] {
|
||||
var choices: [String] = []
|
||||
if let sizeName = selectedSize?.name {
|
||||
let sizePrice = basePizzaPrice
|
||||
if sizePrice > 0 {
|
||||
choices.append("Tamanho: \(sizeName) (+\(formatCurrency(sizePrice)))")
|
||||
} else {
|
||||
choices.append("Tamanho: \(sizeName)")
|
||||
}
|
||||
}
|
||||
if let crustName = selectedCrust?.name, crustName.isEmpty == false {
|
||||
let mod = crustPriceModifier
|
||||
if mod > 0 {
|
||||
choices.append("Borda: \(crustName) (+\(formatCurrency(mod)))")
|
||||
} else {
|
||||
choices.append("Borda: \(crustName)")
|
||||
}
|
||||
}
|
||||
if let doughName = selectedDoughName, doughName.isEmpty == false {
|
||||
choices.append("Massa: \(doughName)")
|
||||
}
|
||||
let flavorCount = selectedFlavorProducts.count
|
||||
for flavor in selectedFlavorProducts {
|
||||
choices.append(flavorCount > 1 ? "Meio \(flavor.name)" : flavor.name)
|
||||
}
|
||||
return choices
|
||||
}
|
||||
|
||||
private var addButtonTitle: String {
|
||||
if canConfirm == false {
|
||||
return "Selecione as opções"
|
||||
}
|
||||
return "Adicionar • \(formatCurrency(totalPrice))"
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ScrollView(showsIndicators: false) {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
screenHeader
|
||||
Rectangle()
|
||||
.fill(AppColors.brandSoft)
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 220)
|
||||
.overlay(
|
||||
Image("placeholder-pizza")
|
||||
.resizable()
|
||||
.scaledToFill()
|
||||
.clipped()
|
||||
)
|
||||
.clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusXL, style: .continuous))
|
||||
|
||||
Text("Escolha seu sabor")
|
||||
.font(AppTypography.heading2)
|
||||
.foregroundStyle(AppColors.textPrimary)
|
||||
|
||||
Text("Escolha o tamanho da sua fome")
|
||||
.font(AppTypography.body)
|
||||
.foregroundStyle(AppColors.textMuted)
|
||||
|
||||
Text(formatCurrency(unitPrice))
|
||||
.font(AppTypography.heading2)
|
||||
.foregroundStyle(AppColors.primary)
|
||||
|
||||
stepSizes
|
||||
if selectedSizeId != nil { stepDoughs }
|
||||
if isDoughReady { stepCrusts }
|
||||
if canShowFlavors { stepFlavors }
|
||||
}
|
||||
.padding(20)
|
||||
.padding(.bottom, 90)
|
||||
}
|
||||
.appBottomSafeAreaInset {
|
||||
HStack(spacing: 12) {
|
||||
HStack(spacing: 10) {
|
||||
Button(action: { if quantity > 1 { quantity -= 1 } }) {
|
||||
Image(systemName: "minus")
|
||||
.font(.system(size: 14, weight: .bold))
|
||||
.foregroundStyle(AppColors.textPrimary)
|
||||
.frame(width: 32, height: 32)
|
||||
.background(AppColors.surface)
|
||||
.clipShape(Circle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(quantity <= 1)
|
||||
|
||||
Text("\(quantity)")
|
||||
.font(AppTypography.heading3)
|
||||
.foregroundStyle(AppColors.textPrimary)
|
||||
.frame(minWidth: 20)
|
||||
|
||||
Button(action: { quantity += 1 }) {
|
||||
Image(systemName: "plus")
|
||||
.font(.system(size: 14, weight: .bold))
|
||||
.foregroundStyle(AppColors.textPrimary)
|
||||
.frame(width: 32, height: 32)
|
||||
.background(AppColors.surface)
|
||||
.clipShape(Circle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
.padding(.horizontal, 8)
|
||||
.frame(height: 48)
|
||||
.background(AppColors.surface)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous))
|
||||
|
||||
PrimaryButton(title: addButtonTitle) {
|
||||
guard canConfirm else { return }
|
||||
let item = CartItemState(
|
||||
id: cartItemId,
|
||||
productId: selectedFlavorProducts.first?.id ?? category.id,
|
||||
storeId: storeId,
|
||||
name: "Escolha seu sabor",
|
||||
imageURL: representativeImage,
|
||||
details: selectedDetailsText,
|
||||
choices: pizzaChoices.isEmpty ? nil : pizzaChoices,
|
||||
addons: selectedAddonsPayload,
|
||||
quantity: quantity,
|
||||
unitPrice: unitPrice
|
||||
)
|
||||
onAdd(item)
|
||||
dismiss()
|
||||
}
|
||||
.disabled(canConfirm == false)
|
||||
}
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.top, 8)
|
||||
.padding(.bottom, 12)
|
||||
.background(.ultraThinMaterial)
|
||||
}
|
||||
.sheet(item: $selectedFlavorForAddons) { flavor in
|
||||
NavigationStack {
|
||||
PizzaFlavorAddonsSheet(
|
||||
flavor: flavor,
|
||||
quantities: Binding(
|
||||
get: { flavorAddonQuantities[flavor.id] ?? [:] },
|
||||
set: { flavorAddonQuantities[flavor.id] = $0 }
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
.background(AppColors.backgroundLight.ignoresSafeArea())
|
||||
.appHiddenNavigationBar()
|
||||
.navigationBarBackButtonHidden(true)
|
||||
.onAppear {
|
||||
applyAutoSelections()
|
||||
let existing = currentQuantityForItemId(cartItemId)
|
||||
if existing > 0 {
|
||||
quantity = existing
|
||||
}
|
||||
}
|
||||
.onChange(of: selectedSizeId) { _, _ in
|
||||
trimFlavorSelectionByLimit()
|
||||
applyAutoSelections()
|
||||
}
|
||||
.onChange(of: selectedFlavorIds) { _, newValue in
|
||||
let selected = newValue
|
||||
flavorAddonQuantities = flavorAddonQuantities.filter { selected.contains($0.key) }
|
||||
}
|
||||
}
|
||||
|
||||
private var screenHeader: some View {
|
||||
ZStack {
|
||||
Text("Monte sua pizza")
|
||||
.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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
295
Sources/PediFoods/Views/Main/ProductDetailSheet.swift
Normal file
@@ -0,0 +1,295 @@
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
struct ProductDetailSheet: View {
|
||||
let product: StoreCatalogProduct
|
||||
let imageURL: String?
|
||||
let storeId: String
|
||||
let currentQuantityForItemId: (String) -> Int
|
||||
let onAdd: (CartItemState) -> Void
|
||||
@Environment(\.dismiss) var dismiss
|
||||
@State var selectedAddonQuantities: [String: Int] = [:]
|
||||
@State var quantity: Int = 0
|
||||
|
||||
private var addonItemsById: [String: StoreAddonItem] {
|
||||
Dictionary(uniqueKeysWithValues: product.addonGroups.flatMap(\.items).map { ($0.id, $0) })
|
||||
}
|
||||
|
||||
private var selectedAddonItems: [(item: StoreAddonItem, quantity: Int)] {
|
||||
selectedAddonQuantities
|
||||
.compactMap { key, qty in
|
||||
guard qty > 0, let item = addonItemsById[key] else { return nil }
|
||||
return (item, qty)
|
||||
}
|
||||
.sorted { $0.item.name < $1.item.name }
|
||||
}
|
||||
|
||||
private var addonsTotal: Double {
|
||||
selectedAddonItems.reduce(0) { partial, pair in
|
||||
partial + (Double(pair.quantity) * (pair.item.price ?? 0))
|
||||
}
|
||||
}
|
||||
|
||||
private var unitPrice: Double {
|
||||
(product.price ?? 0) + addonsTotal
|
||||
}
|
||||
|
||||
private var totalPrice: Double {
|
||||
unitPrice * Double(quantity)
|
||||
}
|
||||
|
||||
private var cartItemId: String {
|
||||
let addonKey = encodedAddonKey
|
||||
return "\(storeId)::\(product.id)::\(addonKey)"
|
||||
}
|
||||
|
||||
private var selectedAddonsSummary: String? {
|
||||
let names = selectedAddonItems.map { pair in
|
||||
pair.quantity > 1 ? "\(pair.item.name) x\(pair.quantity)" : pair.item.name
|
||||
}
|
||||
if names.isEmpty { return nil }
|
||||
return names.joined(separator: ", ")
|
||||
}
|
||||
|
||||
private var selectedAddonsPayload: [CartItemAddonState] {
|
||||
selectedAddonItems.map { pair in
|
||||
CartItemAddonState(
|
||||
id: pair.item.id,
|
||||
name: pair.item.name,
|
||||
quantity: pair.quantity,
|
||||
unitPrice: pair.item.price ?? 0
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private var addButtonTitle: String {
|
||||
if quantity <= 0 {
|
||||
return "Remover do carrinho"
|
||||
}
|
||||
return "Atualizar • \(formatCurrency(totalPrice))"
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ScrollView(showsIndicators: false) {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
screenHeader
|
||||
AsyncStoreImage(imageURL: imageURL)
|
||||
.frame(height: 220)
|
||||
.clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusXL, style: .continuous))
|
||||
|
||||
Text(product.name)
|
||||
.font(AppTypography.heading2)
|
||||
.foregroundStyle(AppColors.textPrimary)
|
||||
|
||||
if let description = product.description, description.isEmpty == false {
|
||||
Text(description)
|
||||
.font(AppTypography.body)
|
||||
.foregroundStyle(AppColors.textMuted)
|
||||
}
|
||||
|
||||
Text(formatCurrency(unitPrice))
|
||||
.font(AppTypography.heading2)
|
||||
.foregroundStyle(AppColors.primary)
|
||||
|
||||
if addonsTotal > 0 {
|
||||
Text("Inclui adicionais: \(formatCurrency(addonsTotal))")
|
||||
.font(.caption)
|
||||
.foregroundStyle(AppColors.textMuted)
|
||||
} else {
|
||||
Text("Sem adicionais")
|
||||
.font(.caption)
|
||||
.foregroundStyle(AppColors.textMuted)
|
||||
}
|
||||
|
||||
if product.addonGroups.isEmpty == false {
|
||||
Text("Adicionais")
|
||||
.font(AppTypography.heading3)
|
||||
.foregroundStyle(AppColors.textPrimary)
|
||||
|
||||
ForEach(product.addonGroups) { group in
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text(group.name)
|
||||
.font(AppTypography.heading3)
|
||||
.foregroundStyle(AppColors.textPrimary)
|
||||
|
||||
ForEach(group.items) { item in
|
||||
HStack(spacing: 10) {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(item.name)
|
||||
.font(AppTypography.body)
|
||||
.foregroundStyle(AppColors.textMuted)
|
||||
Text(String(format: "+ R$ %.2f", item.price ?? 0).replacingOccurrences(of: ".", with: ","))
|
||||
.font(.caption)
|
||||
.foregroundStyle(AppColors.textMuted)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
HStack(spacing: 8) {
|
||||
Button(action: { decrementAddon(item.id) }) {
|
||||
Image(systemName: "minus")
|
||||
.font(.system(size: 12, weight: .bold))
|
||||
.foregroundStyle(AppColors.textPrimary)
|
||||
.frame(width: 26, height: 26)
|
||||
.background(AppColors.brandSoft)
|
||||
.clipShape(Circle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(quantity(forAddonId: item.id) <= 0 || quantity <= 0)
|
||||
|
||||
Text("\(quantity(forAddonId: item.id))")
|
||||
.font(AppTypography.heading3)
|
||||
.foregroundStyle(AppColors.textPrimary)
|
||||
.frame(minWidth: 18)
|
||||
|
||||
Button(action: { incrementAddon(item.id) }) {
|
||||
Image(systemName: "plus")
|
||||
.font(.system(size: 12, weight: .bold))
|
||||
.foregroundStyle(AppColors.textPrimary)
|
||||
.frame(width: 26, height: 26)
|
||||
.background(AppColors.tertiary)
|
||||
.clipShape(Circle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(quantity <= 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(12)
|
||||
.background(AppColors.surface)
|
||||
.clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous))
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(20)
|
||||
.padding(.bottom, 80)
|
||||
}
|
||||
.appBottomSafeAreaInset {
|
||||
HStack(spacing: 12) {
|
||||
HStack(spacing: 10) {
|
||||
Button(action: { if quantity > 0 { quantity -= 1 } }) {
|
||||
Image(systemName: "minus")
|
||||
.font(.system(size: 14, weight: .bold))
|
||||
.foregroundStyle(AppColors.textPrimary)
|
||||
.frame(width: 32, height: 32)
|
||||
.background(AppColors.surface)
|
||||
.clipShape(Circle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(quantity <= 0)
|
||||
|
||||
Text("\(quantity)")
|
||||
.font(AppTypography.heading3)
|
||||
.foregroundStyle(AppColors.textPrimary)
|
||||
.frame(minWidth: 20)
|
||||
|
||||
Button(action: { quantity += 1 }) {
|
||||
Image(systemName: "plus")
|
||||
.font(.system(size: 14, weight: .bold))
|
||||
.foregroundStyle(AppColors.textPrimary)
|
||||
.frame(width: 32, height: 32)
|
||||
.background(AppColors.surface)
|
||||
.clipShape(Circle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
.padding(.horizontal, 8)
|
||||
.frame(height: 48)
|
||||
.background(AppColors.surface)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous))
|
||||
.buttonStyle(.plain)
|
||||
|
||||
PrimaryButton(title: addButtonTitle) {
|
||||
let item = CartItemState(
|
||||
id: cartItemId,
|
||||
productId: product.id,
|
||||
storeId: storeId,
|
||||
name: product.name,
|
||||
imageURL: imageURL,
|
||||
details: selectedAddonsSummary,
|
||||
addons: selectedAddonsPayload,
|
||||
quantity: quantity,
|
||||
unitPrice: unitPrice
|
||||
)
|
||||
onAdd(item)
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.top, 8)
|
||||
.padding(.bottom, 12)
|
||||
.background(.ultraThinMaterial)
|
||||
}
|
||||
.background(AppColors.backgroundLight.ignoresSafeArea())
|
||||
.appHiddenNavigationBar()
|
||||
.navigationBarBackButtonHidden(true)
|
||||
.onAppear {
|
||||
let existing = currentQuantityForItemId(cartItemId)
|
||||
quantity = existing > 0 ? existing : 1
|
||||
}
|
||||
.onChange(of: selectedAddonQuantities) { _, _ in
|
||||
// Keep the main quantity stable when changing addon quantities.
|
||||
// Only hydrate from cart if this exact configuration already exists.
|
||||
let existingQuantity = currentQuantityForItemId(cartItemId)
|
||||
if existingQuantity > 0 {
|
||||
quantity = existingQuantity
|
||||
}
|
||||
}
|
||||
.onChange(of: quantity) { _, newValue in
|
||||
if newValue <= 0 {
|
||||
selectedAddonQuantities.removeAll()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var screenHeader: some View {
|
||||
ZStack {
|
||||
Text("Detalhes")
|
||||
.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 func formatCurrency(_ value: Double) -> String {
|
||||
String(format: "R$ %.2f", value).replacingOccurrences(of: ".", with: ",")
|
||||
}
|
||||
|
||||
private var encodedAddonKey: String {
|
||||
let tokens = selectedAddonQuantities
|
||||
.filter { $0.value > 0 }
|
||||
.map { "\($0.key):\($0.value)" }
|
||||
.sorted()
|
||||
return tokens.isEmpty ? "base" : tokens.joined(separator: ",")
|
||||
}
|
||||
|
||||
private func quantity(forAddonId addonId: String) -> Int {
|
||||
selectedAddonQuantities[addonId] ?? 0
|
||||
}
|
||||
|
||||
private func incrementAddon(_ addonId: String) {
|
||||
selectedAddonQuantities[addonId, default: 0] += 1
|
||||
}
|
||||
|
||||
private func decrementAddon(_ addonId: String) {
|
||||
let current = selectedAddonQuantities[addonId] ?? 0
|
||||
if current <= 1 {
|
||||
selectedAddonQuantities.removeValue(forKey: addonId)
|
||||
} else {
|
||||
selectedAddonQuantities[addonId] = current - 1
|
||||
}
|
||||
}
|
||||
}
|
||||
305
Sources/PediFoods/Views/Main/ProfileView.swift
Normal file
@@ -0,0 +1,305 @@
|
||||
import SwiftUI
|
||||
#if canImport(LCEssentials) && os(iOS)
|
||||
import LCEssentials
|
||||
#endif
|
||||
#if canImport(UIKit)
|
||||
import UIKit
|
||||
#endif
|
||||
|
||||
struct ProfileView: View {
|
||||
@Binding var root: RootFlow
|
||||
@Binding var selectedTab: MainTab
|
||||
let tokenStore: TokenStore
|
||||
@Binding var appState: AppState
|
||||
@State var openAddressesOnboarding = false
|
||||
@State var onboardingMessage: String? = nil
|
||||
@State var showLogoutAlert = false
|
||||
@State private var openOrders = false
|
||||
let tabBarClearance: CGFloat = 120
|
||||
|
||||
var body: some View {
|
||||
ScrollView(showsIndicators: false) {
|
||||
VStack(spacing: 18) {
|
||||
NavigationLink {
|
||||
UserProfileView(appState: $appState)
|
||||
} label: {
|
||||
header
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
VStack(spacing: 14) {
|
||||
NavigationLink {
|
||||
OrdersView(appState: $appState)
|
||||
} label: {
|
||||
ProfileMenuRow(icon: "list.bullet.clipboard.fill", title: "Meus Pedidos")
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
NavigationLink {
|
||||
AddressesView(message: nil, appState: $appState)
|
||||
} label: {
|
||||
ProfileMenuRow(icon: "mappin.circle.fill", title: "Meus Endereços")
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
NavigationLink {
|
||||
SavedCardsView(appState: $appState)
|
||||
} label: {
|
||||
ProfileMenuRow(icon: "creditcard.fill", title: "Meus Cartões")
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
NavigationLink {
|
||||
MyReviewsView()
|
||||
} label: {
|
||||
ProfileMenuRow(icon: "star.fill", title: "Minhas Avaliações")
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
if appState.featureFlags.isEnabled("at.cupons") {
|
||||
NavigationLink {
|
||||
Text("Cupons de Desconto")
|
||||
} label: {
|
||||
ProfileMenuRow(icon: "ticket.fill", title: "Cupons de Desconto", badge: "NOVO")
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
// NavigationLink {
|
||||
// Text("Ajuda")
|
||||
// } label: {
|
||||
// ProfileMenuRow(icon: "gearshape.fill", title: "Configurações")
|
||||
// }
|
||||
// .buttonStyle(.plain)
|
||||
}
|
||||
.padding(.horizontal, 20)
|
||||
|
||||
Button(action: { showLogoutAlert = true }) {
|
||||
HStack(spacing: 10) {
|
||||
Image(systemName: "rectangle.portrait.and.arrow.right")
|
||||
.font(.system(size: 18, weight: .semibold))
|
||||
Text("Sair da Conta")
|
||||
.font(AppTypography.heading3)
|
||||
}
|
||||
.foregroundStyle(Color.red)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.padding(.top, 10)
|
||||
.padding(.horizontal, 20)
|
||||
|
||||
Text("Versão 1.0b")
|
||||
.font(.system(size: 16, weight: .medium))
|
||||
.foregroundStyle(AppColors.textMuted)
|
||||
.padding(.bottom, 12)
|
||||
}
|
||||
}
|
||||
.ignoresSafeArea(edges: .top)
|
||||
.appBottomSafeAreaInset {
|
||||
Rectangle()
|
||||
.fill(AppColors.backgroundLight.opacity(0.8))
|
||||
.frame(height: tabBarClearance)
|
||||
.padding(.bottom, -UIDevice.bottomNotch)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.background(AppColors.backgroundLight)
|
||||
.alert("Sair da conta?", isPresented: $showLogoutAlert) {
|
||||
Button("Cancelar", role: .cancel) {}
|
||||
Button("Sair", role: .destructive) {
|
||||
logout()
|
||||
}
|
||||
} message: {
|
||||
Text("Tem certeza que deseja sair da sua conta?")
|
||||
}
|
||||
.onAppear {
|
||||
guard let message = appState.address.onboardingMessage else {
|
||||
return
|
||||
}
|
||||
onboardingMessage = message
|
||||
appState.address.onboardingMessage = nil
|
||||
openAddressesOnboarding = true
|
||||
}
|
||||
.sheet(isPresented: $openAddressesOnboarding) {
|
||||
NavigationStack {
|
||||
AddressesView(message: onboardingMessage, appState: $appState)
|
||||
}
|
||||
}
|
||||
.navigationDestination(isPresented: $openOrders) {
|
||||
OrdersView(appState: $appState)
|
||||
}
|
||||
.onChange(of: appState.shouldNavigateToOrders) { _, val in
|
||||
if val {
|
||||
appState.shouldNavigateToOrders = false
|
||||
openOrders = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var header: some View {
|
||||
VStack(spacing: 10) {
|
||||
ZStack(alignment: .bottomTrailing) {
|
||||
Circle()
|
||||
.fill(Color.white.opacity(0.18))
|
||||
.frame(width: 96, height: 96)
|
||||
.overlay(
|
||||
Group {
|
||||
if let picture = profilePictureURL {
|
||||
AsyncStoreImage(imageURL: picture)
|
||||
.frame(width: 92, height: 92)
|
||||
.clipShape(Circle())
|
||||
} else {
|
||||
Text(profileInitials)
|
||||
.font(.system(size: 30, weight: .bold))
|
||||
.foregroundStyle(AppColors.textInverse)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
Circle()
|
||||
.fill(AppColors.tertiary)
|
||||
.frame(width: 36, height: 36)
|
||||
.overlay(
|
||||
Image(systemName: "pencil")
|
||||
.font(.system(size: 15, weight: .bold))
|
||||
.foregroundStyle(AppColors.textPrimary)
|
||||
)
|
||||
.overlay(
|
||||
Circle()
|
||||
.stroke(Color.black.opacity(0.15), lineWidth: 1)
|
||||
)
|
||||
}
|
||||
|
||||
Text(profileName)
|
||||
.font(.system(size: 22, weight: .heavy))
|
||||
.foregroundStyle(AppColors.textInverse)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.8)
|
||||
|
||||
HStack(spacing: 8) {
|
||||
Text("Ver Perfil")
|
||||
.font(.system(size: 16, weight: .medium))
|
||||
Image(systemName: "arrow.right")
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
}
|
||||
.foregroundStyle(AppColors.tertiary)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.top, 54)
|
||||
.padding(.bottom, 32)
|
||||
.background(headerGradient)
|
||||
.clipShape(
|
||||
ProfileHeaderShape(
|
||||
topLeadingRadius: 0,
|
||||
bottomLeadingRadius: 42,
|
||||
bottomTrailingRadius: 42,
|
||||
topTrailingRadius: 0
|
||||
)
|
||||
)
|
||||
.ignoresSafeArea(edges: .top)
|
||||
}
|
||||
|
||||
private var headerGradient: LinearGradient {
|
||||
LinearGradient(
|
||||
colors: [Color(hex: "#123221"), Color(hex: "#0F2A1C")],
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing
|
||||
)
|
||||
}
|
||||
|
||||
private var profileName: String {
|
||||
let trimmed = appState.profile.name.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return trimmed.isEmpty ? "Alex Silva" : trimmed
|
||||
}
|
||||
|
||||
private var profileInitials: String {
|
||||
let parts = profileName.split(separator: " ").prefix(2)
|
||||
let joined = parts.compactMap { $0.first }.map(String.init).joined()
|
||||
return joined.isEmpty ? "AS" : joined.uppercased()
|
||||
}
|
||||
|
||||
private var profilePictureURL: String? {
|
||||
let raw = appState.profile.profilePicture.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard raw.isEmpty == false else { return nil }
|
||||
return ImageSourceResolver.resolve(raw)
|
||||
}
|
||||
|
||||
private func logout() {
|
||||
tokenStore.clear()
|
||||
SessionStateStore.clearActiveUser()
|
||||
SessionStateStore.clearTrackedOrders()
|
||||
AppContentCache.shared.invalidate()
|
||||
AppImageCache.shared.invalidateAll()
|
||||
appState = AppState()
|
||||
root = .auth
|
||||
}
|
||||
}
|
||||
|
||||
struct ProfileMenuRow: View {
|
||||
let icon: String
|
||||
let title: String
|
||||
var badge: String? = nil
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 12) {
|
||||
Circle()
|
||||
.fill(Color(hex: "#E9F0E2"))
|
||||
.frame(width: 44, height: 44)
|
||||
.overlay(
|
||||
Image(systemName: icon)
|
||||
.font(.system(size: 18, weight: .semibold))
|
||||
.foregroundStyle(Color(hex: "#173824"))
|
||||
)
|
||||
|
||||
Text(title)
|
||||
.font(.system(size: 16, weight: .semibold))
|
||||
.foregroundStyle(Color(hex: "#0F1A34"))
|
||||
|
||||
Spacer(minLength: 10)
|
||||
|
||||
if let badge {
|
||||
Text(badge)
|
||||
.font(.system(size: 12, weight: .bold))
|
||||
.foregroundStyle(Color(hex: "#1C2A1C"))
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 6)
|
||||
.background(Color(hex: "#EAF1D6"))
|
||||
.clipShape(Capsule())
|
||||
}
|
||||
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.system(size: 15, weight: .semibold))
|
||||
.foregroundStyle(Color(hex: "#BFC7D4"))
|
||||
}
|
||||
.padding(.horizontal, 18)
|
||||
.padding(.vertical, 14)
|
||||
.background(AppColors.surface)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 20, style: .continuous))
|
||||
.shadow(color: Color.black.opacity(0.02), radius: 6, y: 2)
|
||||
}
|
||||
}
|
||||
|
||||
struct ProfileHeaderShape: Shape {
|
||||
var topLeadingRadius: CGFloat
|
||||
var bottomLeadingRadius: CGFloat
|
||||
var bottomTrailingRadius: CGFloat
|
||||
var topTrailingRadius: CGFloat
|
||||
|
||||
func path(in rect: CGRect) -> Path {
|
||||
let tl = min(min(topLeadingRadius, rect.width / 2), rect.height / 2)
|
||||
let tr = min(min(topTrailingRadius, rect.width / 2), rect.height / 2)
|
||||
let bl = min(min(bottomLeadingRadius, rect.width / 2), rect.height / 2)
|
||||
let br = min(min(bottomTrailingRadius, rect.width / 2), rect.height / 2)
|
||||
|
||||
var path = Path()
|
||||
path.move(to: CGPoint(x: rect.minX + tl, y: rect.minY))
|
||||
path.addLine(to: CGPoint(x: rect.maxX - tr, y: rect.minY))
|
||||
path.addArc(center: CGPoint(x: rect.maxX - tr, y: rect.minY + tr), radius: tr, startAngle: .degrees(-90), endAngle: .degrees(0), clockwise: false)
|
||||
path.addLine(to: CGPoint(x: rect.maxX, y: rect.maxY - br))
|
||||
path.addArc(center: CGPoint(x: rect.maxX - br, y: rect.maxY - br), radius: br, startAngle: .degrees(0), endAngle: .degrees(90), clockwise: false)
|
||||
path.addLine(to: CGPoint(x: rect.minX + bl, y: rect.maxY))
|
||||
path.addArc(center: CGPoint(x: rect.minX + bl, y: rect.maxY - bl), radius: bl, startAngle: .degrees(90), endAngle: .degrees(180), clockwise: false)
|
||||
path.addLine(to: CGPoint(x: rect.minX, y: rect.minY + tl))
|
||||
path.addArc(center: CGPoint(x: rect.minX + tl, y: rect.minY + tl), radius: tl, startAngle: .degrees(180), endAngle: .degrees(270), clockwise: false)
|
||||
path.closeSubpath()
|
||||
return path
|
||||
}
|
||||
}
|
||||
1615
Sources/PediFoods/Views/Main/ReviewsView.swift
Normal file
244
Sources/PediFoods/Views/Main/SavedCardsView.swift
Normal file
@@ -0,0 +1,244 @@
|
||||
import SwiftUI
|
||||
|
||||
struct SavedCardsView: View {
|
||||
@Binding var appState: AppState
|
||||
@Environment(\.dismiss) var dismiss
|
||||
|
||||
@State private var cards: [SavedCard] = []
|
||||
@State private var isLoading = false
|
||||
@State private var errorMessage: String? = nil
|
||||
@State private var openSwipeRowId: String? = nil
|
||||
@State private var deletingCardId: String? = nil
|
||||
@State private var showAddCard = false
|
||||
|
||||
private var canDelete: Bool { cards.count > 1 }
|
||||
private let tabBarClearance: CGFloat = 96
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
AppColors.backgroundLight.ignoresSafeArea()
|
||||
|
||||
ScrollView(showsIndicators: false) {
|
||||
VStack(spacing: 20) {
|
||||
screenHeader
|
||||
|
||||
VStack(spacing: 12) {
|
||||
if isLoading {
|
||||
ProgressView()
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.top, 32)
|
||||
} else if let errorMessage {
|
||||
Text(errorMessage)
|
||||
.font(AppTypography.body)
|
||||
.foregroundStyle(AppColors.textMuted)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.top, 32)
|
||||
} else if cards.isEmpty {
|
||||
Text("Nenhum cartão cadastrado")
|
||||
.font(AppTypography.body)
|
||||
.foregroundStyle(AppColors.textMuted)
|
||||
.padding(.top, 32)
|
||||
} else {
|
||||
ForEach(cards) { card in
|
||||
if canDelete {
|
||||
SwipeToDeleteAddressRow(
|
||||
rowId: card.id,
|
||||
openRowId: $openSwipeRowId,
|
||||
isDeleting: deletingCardId == card.id,
|
||||
onDelete: { deleteCard(card) }
|
||||
) {
|
||||
cardRow(card)
|
||||
.appContentShape(Rectangle())
|
||||
.onTapGesture {
|
||||
if openSwipeRowId == card.id { openSwipeRowId = nil }
|
||||
}
|
||||
}
|
||||
.id(card.id)
|
||||
.opacity(deletingCardId == card.id ? 0.6 : 1.0)
|
||||
.disabled(deletingCardId != nil)
|
||||
} else {
|
||||
cardRow(card)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.top, 18)
|
||||
.padding(.bottom, tabBarClearance + 20)
|
||||
}
|
||||
|
||||
VStack {
|
||||
Spacer()
|
||||
addCardButton
|
||||
.padding(.bottom, tabBarClearance)
|
||||
}
|
||||
}
|
||||
.navigationBarBackButtonHidden(true)
|
||||
.appHiddenNavigationBar()
|
||||
.sheet(isPresented: $showAddCard) {
|
||||
NavigationStack {
|
||||
AddCardFormView(appState: appState, isFirstCard: cards.isEmpty) { newCard in
|
||||
cards.append(newCard)
|
||||
}
|
||||
}
|
||||
}
|
||||
.task { await loadCards() }
|
||||
}
|
||||
|
||||
private var screenHeader: some View {
|
||||
ZStack {
|
||||
Text("Meus Cartões")
|
||||
.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 addCardButton: some View {
|
||||
ZStack(alignment: .bottom) {
|
||||
Rectangle()
|
||||
.fill(AppColors.backgroundLight)
|
||||
.frame(height: 136)
|
||||
Button(action: { showAddCard = true }) {
|
||||
HStack(spacing: 12) {
|
||||
Image(systemName: "creditcard.fill")
|
||||
.font(.system(size: 20))
|
||||
Text("Adicionar novo cartã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 cardRow(_ card: SavedCard) -> some View {
|
||||
HStack(spacing: 14) {
|
||||
ZStack {
|
||||
RoundedRectangle(cornerRadius: 10, style: .continuous)
|
||||
.fill(AppColors.backgroundLight)
|
||||
.frame(width: 52, height: 52)
|
||||
if let logo = brandLogoName(for: card.brand) {
|
||||
Image(logo)
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.frame(width: 36, height: 24)
|
||||
} else {
|
||||
Image(systemName: "creditcard.fill")
|
||||
.font(.system(size: 22))
|
||||
.foregroundStyle(AppColors.textMuted)
|
||||
}
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
HStack(spacing: 6) {
|
||||
Text(card.displayLabel)
|
||||
.font(AppTypography.heading3)
|
||||
.foregroundStyle(AppColors.textPrimary)
|
||||
if card.isDefault {
|
||||
Text("Principal")
|
||||
.font(AppTypography.caption)
|
||||
.foregroundStyle(AppColors.primary)
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 3)
|
||||
.background(AppColors.brandSoft)
|
||||
.clipShape(Capsule())
|
||||
}
|
||||
}
|
||||
Text("Vence \(card.expiryLabel)")
|
||||
.font(AppTypography.caption)
|
||||
.foregroundStyle(AppColors.textMuted)
|
||||
if !canDelete {
|
||||
Text("Ao menos um cartão deve permanecer")
|
||||
.font(AppTypography.caption)
|
||||
.foregroundStyle(AppColors.textMuted.opacity(0.7))
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.padding(14)
|
||||
.background(AppColors.surface)
|
||||
.clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous))
|
||||
}
|
||||
|
||||
private func brandLogoName(for brand: String?) -> String? {
|
||||
switch brand?.lowercased()
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
.folding(options: .diacriticInsensitive, locale: .current) {
|
||||
case "visa": return "visacard_logo"
|
||||
case "mastercard", "master": return "mastercard_logo"
|
||||
case "amex", "american express", "americanexpress": return "amexcard_logo"
|
||||
case "hipercard": return "hipercard_logo"
|
||||
case "alelo": return "alelocard_logo"
|
||||
case "sodexo": return "sodexo_logo"
|
||||
default: return nil
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func loadCards() async {
|
||||
guard isLoading == false else { return }
|
||||
isLoading = true
|
||||
errorMessage = nil
|
||||
defer { isLoading = false }
|
||||
do {
|
||||
let response = try await ApiService().listCards()
|
||||
if response.error == false {
|
||||
cards = response.result ?? []
|
||||
} else {
|
||||
errorMessage = response.message ?? "Erro ao carregar cartões."
|
||||
}
|
||||
} catch {
|
||||
errorMessage = "Não foi possível carregar seus cartões."
|
||||
}
|
||||
}
|
||||
|
||||
private func deleteCard(_ card: SavedCard) {
|
||||
guard canDelete, deletingCardId == nil else { return }
|
||||
deletingCardId = card.id
|
||||
openSwipeRowId = nil
|
||||
Task {
|
||||
do {
|
||||
let response = try await ApiService().deleteCard(cardId: card.id)
|
||||
if response.error == false {
|
||||
cards.removeAll { $0.id == card.id }
|
||||
} else {
|
||||
SnackbarCenter.shared.show(
|
||||
title: response.message ?? "Não foi possível excluir o cartão.",
|
||||
style: .error,
|
||||
icon: "xmark.octagon.fill",
|
||||
duration: 3.0
|
||||
)
|
||||
}
|
||||
} catch {
|
||||
SnackbarCenter.shared.show(
|
||||
title: "Erro ao excluir cartão.",
|
||||
style: .error,
|
||||
icon: "xmark.octagon.fill",
|
||||
duration: 3.0
|
||||
)
|
||||
}
|
||||
deletingCardId = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
32
Sources/PediFoods/Views/Main/StoreDetailPizzaSupport.swift
Normal file
@@ -0,0 +1,32 @@
|
||||
import Foundation
|
||||
|
||||
struct StoreCatalogListItem: Identifiable {
|
||||
let id: String
|
||||
let product: StoreCatalogProduct
|
||||
let title: String
|
||||
let description: String?
|
||||
let imageURL: String?
|
||||
let isPizzaSummary: Bool
|
||||
let pizzaCategoryId: String?
|
||||
let pizzaProductIds: [String]
|
||||
|
||||
init(
|
||||
id: String,
|
||||
product: StoreCatalogProduct,
|
||||
title: String,
|
||||
description: String?,
|
||||
imageURL: String?,
|
||||
isPizzaSummary: Bool = false,
|
||||
pizzaCategoryId: String? = nil,
|
||||
pizzaProductIds: [String] = []
|
||||
) {
|
||||
self.id = id
|
||||
self.product = product
|
||||
self.title = title
|
||||
self.description = description
|
||||
self.imageURL = imageURL
|
||||
self.isPizzaSummary = isPizzaSummary
|
||||
self.pizzaCategoryId = pizzaCategoryId
|
||||
self.pizzaProductIds = pizzaProductIds
|
||||
}
|
||||
}
|
||||
76
Sources/PediFoods/Views/Main/StoreDetailSupport.swift
Normal file
@@ -0,0 +1,76 @@
|
||||
import SwiftUI
|
||||
|
||||
struct CategoryHeaderOffsetPreferenceKey: PreferenceKey {
|
||||
static let defaultValue: [String: CGFloat] = [:]
|
||||
|
||||
static func reduce(value: inout [String: CGFloat], nextValue: () -> [String: CGFloat]) {
|
||||
value.merge(nextValue(), uniquingKeysWith: { _, new in new })
|
||||
}
|
||||
}
|
||||
|
||||
enum StoreDetailScrollCoordinateSpace {
|
||||
static let name = "store-detail-scroll"
|
||||
}
|
||||
|
||||
struct ScrollOffsetPreferenceKey: PreferenceKey {
|
||||
static let defaultValue: CGFloat = 0
|
||||
|
||||
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
|
||||
value = nextValue()
|
||||
}
|
||||
}
|
||||
|
||||
struct ScrollOffsetReader: View {
|
||||
@Binding var offsetY: CGFloat
|
||||
@State private var baseline: CGFloat? = nil
|
||||
|
||||
var body: some View {
|
||||
Color.clear
|
||||
.frame(height: 0)
|
||||
.background(
|
||||
GeometryReader { geometry in
|
||||
Color.clear.preference(
|
||||
key: ScrollOffsetPreferenceKey.self,
|
||||
value: geometry.frame(in: .named(StoreDetailScrollCoordinateSpace.name)).minY
|
||||
)
|
||||
}
|
||||
)
|
||||
.onPreferenceChange(ScrollOffsetPreferenceKey.self) { minY in
|
||||
if baseline == nil { baseline = minY }
|
||||
let offset = (baseline ?? 0) - minY
|
||||
if abs(offsetY - offset) > 0.5 {
|
||||
offsetY = offset
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct AsyncStoreImage: View {
|
||||
let imageURL: String?
|
||||
var fallbackImageName: String = "placeholder-product"
|
||||
var fitMode: ImageFitMode = .fill
|
||||
|
||||
var body: some View {
|
||||
GeometryReader { geometry in
|
||||
CachedRemoteImage(imageURL: imageURL, fitMode: fitMode) {
|
||||
fallback
|
||||
}
|
||||
// Lock the scaledToFill image to the actually proposed box.
|
||||
// Without this, a wide/landscape source image's fill-scaled
|
||||
// ideal width can exceed the box and balloon the parent
|
||||
// ZStack's ideal width, pushing sibling content off-screen.
|
||||
.frame(width: geometry.size.width, height: geometry.size.height)
|
||||
.clipped()
|
||||
}
|
||||
.background(AppColors.brandSoft)
|
||||
}
|
||||
|
||||
private var fallback: some View {
|
||||
Image(fallbackImageName)
|
||||
.resizable()
|
||||
.scaledToFill()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
367
Sources/PediFoods/Views/Main/StoreDetailView+Components.swift
Normal file
@@ -0,0 +1,367 @@
|
||||
import SwiftUI
|
||||
|
||||
extension StoreDetailView {
|
||||
|
||||
var topSection: some View {
|
||||
ZStack(alignment: .top) {
|
||||
heroSection
|
||||
.frame(height: topSectionHeight)
|
||||
|
||||
// Hard cut: cover cannot appear below this line.
|
||||
Rectangle()
|
||||
.fill(AppColors.backgroundLight)
|
||||
.frame(height: max(0, topSectionHeight - coverVisibleUntilY))
|
||||
.offset(y: coverVisibleUntilY)
|
||||
|
||||
summaryCard
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.top, cardTopInset)
|
||||
|
||||
storeLogoBadge
|
||||
.padding(.top, cardTopInset - (storeLogoSize / 2))
|
||||
}
|
||||
.frame(height: topSectionHeight)
|
||||
}
|
||||
|
||||
var heroSection: some View {
|
||||
ZStack(alignment: .top) {
|
||||
AsyncStoreImage(imageURL: resolvedURL(storeCoverURL), fitMode: .heightFit)
|
||||
.frame(height: topSectionHeight + stretchAmount)
|
||||
.offset(y: -stretchAmount)
|
||||
.ignoresSafeArea(.container, edges: .top)
|
||||
|
||||
LinearGradient(
|
||||
colors: [Color.black.opacity(0.32), Color.black.opacity(0.05)],
|
||||
startPoint: .top,
|
||||
endPoint: .bottom
|
||||
)
|
||||
.frame(height: topSectionHeight)
|
||||
.ignoresSafeArea(.container, edges: .top)
|
||||
.allowsHitTesting(false)
|
||||
|
||||
VStack(spacing: 0) {
|
||||
HStack {
|
||||
heroIconButton(icon: "chevron.left") {
|
||||
dismiss()
|
||||
}
|
||||
Spacer()
|
||||
heroIconButton(icon: "magnifyingglass") {}
|
||||
heroIconButton(
|
||||
icon: isFavoriteStore ? "heart.fill" : "heart",
|
||||
foregroundStyle: isFavoriteStore ? Color.red : Color.white
|
||||
) {
|
||||
Task {
|
||||
await toggleFavoriteStore()
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.top, UIDevice.topNotch)
|
||||
|
||||
Spacer()
|
||||
|
||||
Text("RESTAURANT")
|
||||
.font(AppTypography.overline)
|
||||
.tracking(1.8)
|
||||
.foregroundStyle(Color.white.opacity(0.92))
|
||||
.padding(.bottom, 14)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var storeLogoBadge: some View {
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(AppColors.surface)
|
||||
.frame(width: storeLogoSize, height: storeLogoSize)
|
||||
.overlay(
|
||||
Circle()
|
||||
.stroke(Color.white, lineWidth: 0.1)
|
||||
)
|
||||
|
||||
AsyncStoreImage(imageURL: resolvedURL(storeLogoURL))
|
||||
.frame(width: storeLogoSize - 10, height: storeLogoSize - 10)
|
||||
.clipShape(Circle())
|
||||
}
|
||||
.shadow(color: Color.black.opacity(0.10), radius: 8, y: 3)
|
||||
}
|
||||
|
||||
var summaryCard: some View {
|
||||
VStack(spacing: 0) {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
HStack(alignment: .top) {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text(storeName)
|
||||
.font(AppTypography.heading2)
|
||||
.foregroundStyle(AppColors.textPrimary)
|
||||
.lineLimit(2)
|
||||
.padding(.top, 30)
|
||||
|
||||
Text(storeSubtitle)
|
||||
.font(.caption)
|
||||
.foregroundStyle(AppColors.textMuted)
|
||||
.lineLimit(1)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
ratingChip
|
||||
}
|
||||
|
||||
HStack(spacing: 0) {
|
||||
statItem(title: "DISTÂNCIA", value: distanceValueLabel)
|
||||
Divider().frame(height: 34)
|
||||
if let deliveryTime = info?.deliveryTime {
|
||||
statItem(title: "TEMPO MIN.", value: deliveryTime+" min.")
|
||||
} else {
|
||||
statItem(title: "TEMPO", value: "--")
|
||||
}
|
||||
Divider().frame(height: 34)
|
||||
statItem(title: "PED. MIN.", value: deliveryValueLabel)
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
.padding(16)
|
||||
|
||||
if isStoreOpen == false {
|
||||
Text(closedStoreBannerText)
|
||||
.font(AppTypography.heading3)
|
||||
.foregroundStyle(Color.white)
|
||||
.frame(maxWidth: .infinity, minHeight: closedBannerHeight)
|
||||
.background(AppColors.brandDark)
|
||||
}
|
||||
}
|
||||
.frame(height: summaryCardHeight)
|
||||
.background(AppColors.surface)
|
||||
.clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusXL, style: .continuous))
|
||||
.shadow(color: AppShadow.soft.color, radius: AppShadow.soft.radius, y: AppShadow.soft.y)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
var sectionedProducts: some View {
|
||||
// Once we have categories loaded, keep showing them regardless of a
|
||||
// subsequent refresh's isLoading/errorMessage state — a failed or
|
||||
// in-flight pull-to-refresh must never hide already-loaded content.
|
||||
if categories.isEmpty == false {
|
||||
sectionedProductsList
|
||||
} else if isLoading {
|
||||
VStack(spacing: 10) {
|
||||
ProgressView()
|
||||
Text("Carregando cardápio...")
|
||||
.font(AppTypography.body)
|
||||
.foregroundStyle(AppColors.textMuted)
|
||||
}
|
||||
.frame(maxWidth: .infinity, minHeight: 280, alignment: .center)
|
||||
} else if let errorMessage {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Text(errorMessage)
|
||||
.font(AppTypography.body)
|
||||
.foregroundStyle(AppColors.textMuted)
|
||||
Button("Tentar novamente") {
|
||||
Task { await loadStoreData() }
|
||||
}
|
||||
.font(AppTypography.heading3)
|
||||
.foregroundStyle(AppColors.primary)
|
||||
}
|
||||
.padding(.top, 16)
|
||||
} else {
|
||||
Text("Cardápio indisponível no momento.")
|
||||
.font(AppTypography.body)
|
||||
.foregroundStyle(AppColors.textMuted)
|
||||
.padding(.top, 16)
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.bottom, 120)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var sectionedProductsList: some View {
|
||||
ForEach(categories, id: \.id) { category in
|
||||
Text(category.name)
|
||||
.font(AppTypography.heading2)
|
||||
.foregroundStyle(AppColors.textPrimary)
|
||||
.id(sectionAnchorId(for: category.id))
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.top, 8)
|
||||
.padding(.bottom, 8)
|
||||
.background(AppColors.backgroundLight)
|
||||
.background(
|
||||
GeometryReader { geometry in
|
||||
Color.clear.preference(
|
||||
key: CategoryHeaderOffsetPreferenceKey.self,
|
||||
value: [category.id: geometry.frame(in: .global).minY]
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
VStack(spacing: 12) {
|
||||
ForEach(listItems(for: category)) { item in
|
||||
productCard(item, in: category)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.bottom, 8)
|
||||
}
|
||||
Color.clear.frame(height: 140)
|
||||
}
|
||||
|
||||
func categoryTabs(proxy: ScrollViewProxy, isPinned: Bool = false) -> some View {
|
||||
let safeTop: CGFloat = {
|
||||
guard isPinned else { return 0 }
|
||||
#if canImport(UIKit)
|
||||
return UIDevice.appSafeAreaTop
|
||||
#else
|
||||
return 0
|
||||
#endif
|
||||
}()
|
||||
|
||||
return ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: 10) {
|
||||
ForEach(categories, id: \.id) { category in
|
||||
let active = selectedCategoryId == category.id
|
||||
Button {
|
||||
selectedCategoryId = category.id
|
||||
isProgrammaticCategoryScroll = true
|
||||
withAnimation(.easeInOut(duration: 0.25)) {
|
||||
proxy.scrollTo(sectionAnchorId(for: category.id), anchor: .top)
|
||||
}
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) {
|
||||
isProgrammaticCategoryScroll = false
|
||||
}
|
||||
} label: {
|
||||
Text(category.name)
|
||||
.font(AppTypography.heading3)
|
||||
.foregroundStyle(active ? AppColors.textInverse : AppColors.textMuted)
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 9)
|
||||
.background(active ? AppColors.primary : AppColors.surface)
|
||||
.clipShape(Capsule())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 10)
|
||||
}
|
||||
.padding(.top, safeTop)
|
||||
.background(AppColors.backgroundLight)
|
||||
.animation(.easeInOut(duration: 0.15), value: isPinned)
|
||||
}
|
||||
|
||||
func productCard(_ item: StoreCatalogListItem, in category: StoreCatalogCategory) -> some View {
|
||||
let product = item.product
|
||||
let hasSelectableAddons = product.addonGroups.contains { $0.items.isEmpty == false }
|
||||
|
||||
return HStack(spacing: 12) {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text(item.title)
|
||||
.font(AppTypography.heading3)
|
||||
.foregroundStyle(AppColors.textPrimary)
|
||||
.lineLimit(2)
|
||||
.multilineTextAlignment(.leading)
|
||||
|
||||
if let description = item.description, description.isEmpty == false {
|
||||
Text(description)
|
||||
.font(.caption)
|
||||
.foregroundStyle(AppColors.textMuted)
|
||||
.lineLimit(2)
|
||||
.multilineTextAlignment(.leading)
|
||||
}
|
||||
|
||||
Text(listPriceLabel(for: product, in: category))
|
||||
.font(AppTypography.heading3)
|
||||
.foregroundStyle(AppColors.textPrimary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
ZStack(alignment: .bottomTrailing) {
|
||||
Group {
|
||||
if item.isPizzaSummary {
|
||||
Image("placeholder-pizza")
|
||||
.resizable()
|
||||
.scaledToFill()
|
||||
} else {
|
||||
AsyncStoreImage(imageURL: resolvedURL(item.imageURL))
|
||||
}
|
||||
}
|
||||
.frame(width: 92, height: 92)
|
||||
.clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous))
|
||||
|
||||
Button {
|
||||
guard isStoreOpen else {
|
||||
SnackbarCenter.shared.show(title: "Loja fechada no momento.", style: .warning, icon: "moon.zzz.fill", duration: 2.0)
|
||||
return
|
||||
}
|
||||
if item.isPizzaSummary, let pizzaCategoryId = item.pizzaCategoryId {
|
||||
requestOpenPizzaSheet(categoryId: pizzaCategoryId)
|
||||
return
|
||||
}
|
||||
if hasSelectableAddons == false {
|
||||
let basePrice = product.price ?? 0
|
||||
let item = CartItemState(
|
||||
id: "\(storeId)::\(product.id)::base",
|
||||
productId: product.id,
|
||||
storeId: storeId,
|
||||
name: product.name,
|
||||
imageURL: resolvedURL(product.image),
|
||||
quantity: 1,
|
||||
unitPrice: basePrice
|
||||
)
|
||||
requestAddToCart(item)
|
||||
} else {
|
||||
requestOpenProductSheet(product)
|
||||
}
|
||||
} label: {
|
||||
ZStack(alignment: .leading) {
|
||||
Image(systemName: "plus")
|
||||
.font(.system(size: 16, weight: .bold))
|
||||
.foregroundStyle(AppColors.textPrimary)
|
||||
.frame(width: 30, height: 30)
|
||||
.background(AppColors.tertiary)
|
||||
.clipShape(Circle())
|
||||
|
||||
let qty = quantityInCart(for: item)
|
||||
if qty > 0 {
|
||||
Text("\(qty)")
|
||||
.font(.system(size: 10, weight: .bold))
|
||||
.foregroundStyle(Color.white)
|
||||
.padding(.horizontal, 5)
|
||||
.padding(.vertical, 2)
|
||||
.background(Color.red)
|
||||
.clipShape(Capsule())
|
||||
.offset(x: -6, y: -10)
|
||||
.zIndex(1)
|
||||
}
|
||||
}
|
||||
.offset(x: 3, y: -3)
|
||||
.frame(width: 30, height: 30)
|
||||
.appContentShape(Circle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.frame(width: 30, height: 30)
|
||||
.disabled(isStoreOpen == false)
|
||||
.opacity(isStoreOpen ? 1 : 0.65)
|
||||
.offset(x: 7, y: 7)
|
||||
}
|
||||
}
|
||||
.padding(12)
|
||||
.background(AppColors.surface)
|
||||
.clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous))
|
||||
.appContentShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous))
|
||||
.onTapGesture {
|
||||
guard isStoreOpen else {
|
||||
SnackbarCenter.shared.show(title: "Loja fechada no momento.", style: .warning, icon: "moon.zzz.fill", duration: 2.0)
|
||||
return
|
||||
}
|
||||
if item.isPizzaSummary, let pizzaCategoryId = item.pizzaCategoryId {
|
||||
requestOpenPizzaSheet(categoryId: pizzaCategoryId)
|
||||
return
|
||||
}
|
||||
guard hasSelectableAddons else { return }
|
||||
requestOpenProductSheet(product)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
460
Sources/PediFoods/Views/Main/StoreDetailView+Logic.swift
Normal file
@@ -0,0 +1,460 @@
|
||||
import SwiftUI
|
||||
|
||||
extension StoreDetailView {
|
||||
func heroIconButton(
|
||||
icon: String,
|
||||
foregroundStyle: Color = .white,
|
||||
action: @escaping () -> Void
|
||||
) -> some View {
|
||||
Button(action: action) {
|
||||
Image(systemName: icon)
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
.foregroundStyle(foregroundStyle)
|
||||
.frame(width: 32, height: 32)
|
||||
.background(Color.white.opacity(0.24))
|
||||
.clipShape(Circle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
func statItem(title: String, value: String) -> some View {
|
||||
VStack(spacing: 4) {
|
||||
Text(title)
|
||||
.font(AppTypography.caption)
|
||||
.foregroundStyle(AppColors.textMuted)
|
||||
Text(value)
|
||||
.font(AppTypography.heading3)
|
||||
.foregroundStyle(AppColors.textPrimary)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
|
||||
var ratingChip: some View {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "star.fill")
|
||||
.font(.caption)
|
||||
.foregroundStyle(Color(hex: "#F5B335"))
|
||||
Text(String(format: "%.1f", storeRating ?? 0))
|
||||
.font(AppTypography.caption)
|
||||
.foregroundStyle(AppColors.textPrimary)
|
||||
}
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 6)
|
||||
.background(AppColors.brandSoft)
|
||||
.clipShape(Capsule())
|
||||
}
|
||||
|
||||
var storeSubtitle: String {
|
||||
let category = (storeCategory ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if category.isEmpty { return "Restaurant" }
|
||||
return category
|
||||
}
|
||||
|
||||
var deliveryValueLabel: String {
|
||||
if let minOrder = info?.minOrder {
|
||||
return formatCurrency(minOrder)
|
||||
}
|
||||
return "R$ --"
|
||||
}
|
||||
|
||||
var distanceValueLabel: String {
|
||||
let raw = (storeDistance ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if raw.isEmpty {
|
||||
return "--"
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
var isStoreOpen: Bool {
|
||||
info?.isOpen ?? true
|
||||
}
|
||||
|
||||
var isFavoriteStore: Bool {
|
||||
appState.favorites.storeIds.contains(storeId)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func toggleFavoriteStore() async {
|
||||
guard isFavoriteRequestInFlight == false else { return }
|
||||
guard appState.session.isAuthenticated else {
|
||||
SnackbarCenter.shared.show(title: "Faça login para favoritar lojas.", style: .warning, icon: "person.crop.circle.badge.exclamationmark", duration: 2.5)
|
||||
return
|
||||
}
|
||||
|
||||
let isFavorite = isFavoriteStore
|
||||
isFavoriteRequestInFlight = true
|
||||
defer { isFavoriteRequestInFlight = false }
|
||||
|
||||
do {
|
||||
let response = try await ApiService().setStoreFavorite(storeId: storeId, isFavorite: isFavorite == false)
|
||||
guard response.error == false, let result = response.result else {
|
||||
let message = response.message ?? "Não foi possível atualizar seus favoritos."
|
||||
SnackbarCenter.shared.show(title: message, style: .error, icon: "xmark.octagon.fill", duration: 3.5)
|
||||
return
|
||||
}
|
||||
|
||||
appState.favorites.storeIds = Set(result.favorites)
|
||||
let successTitle = isFavorite
|
||||
? "\(storeName) removida dos favoritos."
|
||||
: "\(storeName) adicionada aos favoritos."
|
||||
let successIcon = isFavorite ? "heart.slash.fill" : "heart.fill"
|
||||
SnackbarCenter.shared.show(title: successTitle, style: .success, icon: successIcon, duration: 2.2)
|
||||
} catch {
|
||||
let message: String
|
||||
if let networkError = error as? NetworkError {
|
||||
message = networkError.errorDescription ?? "Não foi possível atualizar seus favoritos."
|
||||
} else if let serviceError = error as? ApiServiceError {
|
||||
message = serviceError.errorDescription ?? "Não foi possível atualizar seus favoritos."
|
||||
} else {
|
||||
message = "Não foi possível atualizar seus favoritos."
|
||||
}
|
||||
SnackbarCenter.shared.show(title: message, style: .error, icon: "xmark.octagon.fill", duration: 3.5)
|
||||
}
|
||||
}
|
||||
|
||||
var summaryCardHeight: CGFloat {
|
||||
summaryCardBaseHeight + (isStoreOpen ? 0 : closedBannerHeight)
|
||||
}
|
||||
|
||||
var closedStoreBannerText: String {
|
||||
let label = info?.statusLabel?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
if label.isEmpty {
|
||||
return "Loja fechada • Consulte o horário de abertura"
|
||||
}
|
||||
let normalized = label.lowercased()
|
||||
if normalized.hasPrefix("fechado") {
|
||||
let cleaned = label.replacingOccurrences(of: "Fechado", with: "")
|
||||
.replacingOccurrences(of: "fechado", with: "")
|
||||
.trimmingCharacters(in: CharacterSet(charactersIn: " -:•"))
|
||||
if cleaned.isEmpty == false {
|
||||
return "Loja fechada • \(cleaned)"
|
||||
}
|
||||
}
|
||||
return "Loja fechada • \(label)"
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func loadStoreData(forceRefresh: Bool = false) async {
|
||||
// A refresh (pull-to-refresh) that fails must never wipe content the
|
||||
// user is already looking at — only a first load with nothing yet
|
||||
// loaded is allowed to show a blocking error state.
|
||||
let hadExistingContent = categories.isEmpty == false
|
||||
isLoading = true
|
||||
if hadExistingContent == false {
|
||||
errorMessage = nil
|
||||
}
|
||||
|
||||
let infoCacheKey = "store-info:\(storeId)"
|
||||
let catalogCacheKey = "store-catalog:\(storeId)"
|
||||
|
||||
if forceRefresh == false,
|
||||
let cachedInfo: StoreInfoResult = AppContentCache.shared.value(for: infoCacheKey, as: StoreInfoResult.self),
|
||||
let cachedCatalog: [StoreCatalogCategory] = AppContentCache.shared.value(for: catalogCacheKey, as: [StoreCatalogCategory].self) {
|
||||
let normalizedCatalog = StoreCatalogNormalizer.sanitize(categories: cachedCatalog, storeId: storeId)
|
||||
info = cachedInfo
|
||||
categories = normalizedCatalog
|
||||
selectedCategoryId = StoreCatalogNormalizer.preferredCategoryId(
|
||||
from: normalizedCatalog,
|
||||
preferredId: selectedCategoryId
|
||||
)
|
||||
isLoading = false
|
||||
return
|
||||
}
|
||||
|
||||
if forceRefresh {
|
||||
AppContentCache.shared.invalidate(prefix: "store-info:\(storeId)")
|
||||
AppContentCache.shared.invalidate(prefix: "store-catalog:\(storeId)")
|
||||
}
|
||||
|
||||
do {
|
||||
let apiService = ApiService()
|
||||
let infoResponse = try await apiService.storeInfo(storeId: storeId)
|
||||
let catalogResponse = try await apiService.storeCatalog(storeId: storeId)
|
||||
|
||||
if infoResponse.error {
|
||||
isLoading = false
|
||||
reportStoreLoadFailure(infoResponse.message ?? "Não foi possível carregar a loja.", hadExistingContent: hadExistingContent)
|
||||
return
|
||||
}
|
||||
if catalogResponse.error {
|
||||
isLoading = false
|
||||
reportStoreLoadFailure(catalogResponse.message ?? "Não foi possível carregar o catálogo.", hadExistingContent: hadExistingContent)
|
||||
return
|
||||
}
|
||||
|
||||
let normalizedCatalog = StoreCatalogNormalizer.sanitize(
|
||||
categories: catalogResponse.result ?? [],
|
||||
storeId: storeId
|
||||
)
|
||||
|
||||
info = infoResponse.result
|
||||
categories = normalizedCatalog
|
||||
selectedCategoryId = StoreCatalogNormalizer.preferredCategoryId(
|
||||
from: normalizedCatalog,
|
||||
preferredId: selectedCategoryId
|
||||
)
|
||||
if let info = infoResponse.result {
|
||||
AppContentCache.shared.set(info, for: infoCacheKey, ttl: AppCacheTTL.twoHours)
|
||||
}
|
||||
AppContentCache.shared.set(normalizedCatalog, for: catalogCacheKey, ttl: AppCacheTTL.twoHours)
|
||||
errorMessage = nil
|
||||
isLoading = false
|
||||
} catch {
|
||||
isLoading = false
|
||||
// A cancelled request (e.g. the .refreshable task torn down by a
|
||||
// re-render, or superseded by a newer pull) is not a failure —
|
||||
// it never got a response either way, so there is nothing to
|
||||
// report and no content to touch.
|
||||
if isCancelledRequest(error) {
|
||||
return
|
||||
}
|
||||
let message: String
|
||||
if let network = error as? NetworkError {
|
||||
message = network.errorDescription ?? "Erro ao carregar loja."
|
||||
} else if let service = error as? ApiServiceError {
|
||||
message = service.errorDescription ?? "Erro ao carregar loja."
|
||||
} else {
|
||||
message = "Erro ao carregar loja."
|
||||
}
|
||||
reportStoreLoadFailure(message, hadExistingContent: hadExistingContent)
|
||||
}
|
||||
}
|
||||
|
||||
private func isCancelledRequest(_ error: Error) -> Bool {
|
||||
if error is CancellationError {
|
||||
return true
|
||||
}
|
||||
|
||||
if let networkError = error as? NetworkError {
|
||||
switch networkError {
|
||||
case .cancelled:
|
||||
return true
|
||||
case .transportError(let message):
|
||||
let normalized = message.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
return normalized.contains("cancel")
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return error.localizedDescription.lowercased().contains("cancel")
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func reportStoreLoadFailure(_ message: String, hadExistingContent: Bool) {
|
||||
if hadExistingContent {
|
||||
SnackbarCenter.shared.show(title: message, style: .warning, icon: "exclamationmark.triangle.fill", duration: 3.0)
|
||||
} else {
|
||||
errorMessage = message
|
||||
}
|
||||
}
|
||||
|
||||
func resolvedURL(_ raw: String?) -> String? {
|
||||
ImageSourceResolver.resolve(raw)
|
||||
}
|
||||
|
||||
func formatCurrency(_ value: Double?) -> String {
|
||||
guard let value else { return "R$ --" }
|
||||
return String(format: "R$ %.2f", value).replacingOccurrences(of: ".", with: ",")
|
||||
}
|
||||
|
||||
func listPriceLabel(for product: StoreCatalogProduct, in category: StoreCatalogCategory) -> String {
|
||||
guard product.type?.lowercased() == "pizza", product.pizzaPrices.isEmpty == false else {
|
||||
return formatCurrency(product.price)
|
||||
}
|
||||
|
||||
if let firstSizeId = category.pizzaConfig?.sizes.first?.id,
|
||||
let firstSizePrice = product.pizzaPrices[firstSizeId] {
|
||||
return "A partir de \(formatCurrency(firstSizePrice))"
|
||||
}
|
||||
|
||||
if let fallback = product.pizzaPrices.sorted(by: { $0.key < $1.key }).first?.value {
|
||||
return "A partir de \(formatCurrency(fallback))"
|
||||
}
|
||||
|
||||
return formatCurrency(product.price)
|
||||
}
|
||||
|
||||
var topSectionHeight: CGFloat {
|
||||
cardTopInset + summaryCardHeight
|
||||
}
|
||||
|
||||
func sectionAnchorId(for categoryId: String) -> String {
|
||||
"category-section-\(categoryId)"
|
||||
}
|
||||
|
||||
func syncCategoryWithScroll() {
|
||||
guard isLoading == false else { return }
|
||||
guard isProgrammaticCategoryScroll == false else { return }
|
||||
guard categoryHeaderOffsets.isEmpty == false else { return }
|
||||
|
||||
// Section whose header is nearest to the top content area wins.
|
||||
let topThreshold: CGFloat = 180
|
||||
let sorted = categoryHeaderOffsets.sorted { $0.value < $1.value }
|
||||
|
||||
if let current = sorted.last(where: { $0.value <= topThreshold })?.key {
|
||||
selectedCategoryId = current
|
||||
return
|
||||
}
|
||||
|
||||
if let firstVisible = sorted.first?.key {
|
||||
selectedCategoryId = firstVisible
|
||||
}
|
||||
}
|
||||
|
||||
func quantityInCart(for productId: String) -> Int {
|
||||
appState.cart.items
|
||||
.filter { $0.storeId == storeId && $0.productId == productId }
|
||||
.reduce(0) { $0 + $1.quantity }
|
||||
}
|
||||
|
||||
func quantityInCart(for item: StoreCatalogListItem) -> Int {
|
||||
if item.isPizzaSummary {
|
||||
let ids = Set(item.pizzaProductIds)
|
||||
return appState.cart.items
|
||||
.filter { $0.storeId == storeId && ids.contains($0.productId) }
|
||||
.reduce(0) { $0 + $1.quantity }
|
||||
}
|
||||
return quantityInCart(for: item.product.id)
|
||||
}
|
||||
|
||||
func listItems(for category: StoreCatalogCategory) -> [StoreCatalogListItem] {
|
||||
if category.isPizzaCategory {
|
||||
guard let first = category.products.first else { return [] }
|
||||
let representativeImage = category.products
|
||||
.compactMap(\.image)
|
||||
.first { $0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false }
|
||||
|
||||
return [
|
||||
StoreCatalogListItem(
|
||||
id: "\(category.id)::pizza-summary",
|
||||
product: first,
|
||||
title: "Escolha seu sabor",
|
||||
description: "Escolha o tamanho da sua fome",
|
||||
imageURL: representativeImage ?? first.image,
|
||||
isPizzaSummary: true,
|
||||
pizzaCategoryId: category.id,
|
||||
pizzaProductIds: category.products.map(\.id)
|
||||
)
|
||||
]
|
||||
}
|
||||
|
||||
return category.products.map { product in
|
||||
StoreCatalogListItem(
|
||||
id: product.id,
|
||||
product: product,
|
||||
title: product.name,
|
||||
description: product.description,
|
||||
imageURL: product.image
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func requestAddToCart(_ item: CartItemState) {
|
||||
guard isStoreOpen else {
|
||||
SnackbarCenter.shared.show(title: "Loja fechada no momento.", style: .warning, icon: "moon.zzz.fill", duration: 2.0)
|
||||
return
|
||||
}
|
||||
pendingCartAction = .add
|
||||
if shouldAskForStoreSwitch(for: storeId) {
|
||||
pendingCartItem = item
|
||||
pendingProductSheet = nil
|
||||
showSwitchStoreAlert = true
|
||||
return
|
||||
}
|
||||
applyAddToCart(item)
|
||||
}
|
||||
|
||||
func requestSetCartItem(_ item: CartItemState) {
|
||||
guard isStoreOpen || item.quantity <= 0 else {
|
||||
SnackbarCenter.shared.show(title: "Loja fechada no momento.", style: .warning, icon: "moon.zzz.fill", duration: 2.0)
|
||||
return
|
||||
}
|
||||
pendingCartAction = .set
|
||||
if shouldAskForStoreSwitch(for: storeId) && item.quantity > 0 {
|
||||
pendingCartItem = item
|
||||
pendingProductSheet = nil
|
||||
showSwitchStoreAlert = true
|
||||
return
|
||||
}
|
||||
applySetCartItem(item)
|
||||
}
|
||||
|
||||
func requestOpenProductSheet(_ product: StoreCatalogProduct) {
|
||||
guard isStoreOpen else {
|
||||
SnackbarCenter.shared.show(title: "Loja fechada no momento.", style: .warning, icon: "moon.zzz.fill", duration: 2.0)
|
||||
return
|
||||
}
|
||||
pendingCartAction = .openProductSheet
|
||||
if shouldAskForStoreSwitch(for: storeId) {
|
||||
pendingCartItem = nil
|
||||
pendingProductSheet = product
|
||||
showSwitchStoreAlert = true
|
||||
return
|
||||
}
|
||||
selectedProduct = product
|
||||
}
|
||||
|
||||
func requestOpenPizzaSheet(categoryId: String) {
|
||||
guard isStoreOpen else {
|
||||
SnackbarCenter.shared.show(title: "Loja fechada no momento.", style: .warning, icon: "moon.zzz.fill", duration: 2.0)
|
||||
return
|
||||
}
|
||||
pendingCartAction = .openPizzaSheet
|
||||
if shouldAskForStoreSwitch(for: storeId) {
|
||||
pendingCartItem = nil
|
||||
pendingProductSheet = nil
|
||||
pendingPizzaCategoryId = categoryId
|
||||
showSwitchStoreAlert = true
|
||||
return
|
||||
}
|
||||
selectedPizzaCategoryId = categoryId
|
||||
}
|
||||
|
||||
func applyAddToCart(_ item: CartItemState) {
|
||||
if appState.cart.storeId == nil {
|
||||
appState.cart.storeId = storeId
|
||||
appState.cart.storeName = storeName
|
||||
}
|
||||
appState.cart.add(item: item)
|
||||
SnackbarCenter.shared.show(title: "Produto adicionado ao carrinho.", style: .success, icon: "checkmark.seal.fill", duration: 2.0)
|
||||
}
|
||||
|
||||
func applySetCartItem(_ item: CartItemState) {
|
||||
if item.quantity > 0, appState.cart.storeId == nil {
|
||||
appState.cart.storeId = storeId
|
||||
appState.cart.storeName = storeName
|
||||
}
|
||||
appState.cart.set(item: item)
|
||||
if item.quantity > 0 {
|
||||
SnackbarCenter.shared.show(title: "Carrinho atualizado.", style: .success, icon: "checkmark.seal.fill", duration: 1.8)
|
||||
} else {
|
||||
SnackbarCenter.shared.show(title: "Produto removido do carrinho.", style: .success, icon: "checkmark.seal.fill", duration: 1.8)
|
||||
}
|
||||
}
|
||||
|
||||
func currentQuantity(forCartItemId itemId: String) -> Int {
|
||||
appState.cart.items.first(where: { $0.id == itemId })?.quantity ?? 0
|
||||
}
|
||||
|
||||
func shouldAskForStoreSwitch(for targetStoreId: String) -> Bool {
|
||||
guard appState.cart.items.isEmpty == false else { return false }
|
||||
guard let currentStoreId = currentCartStoreId(),
|
||||
currentStoreId.isEmpty == false else { return false }
|
||||
return currentStoreId != targetStoreId
|
||||
}
|
||||
|
||||
func currentCartStoreId() -> String? {
|
||||
if let storeId = appState.cart.storeId, storeId.isEmpty == false {
|
||||
return storeId
|
||||
}
|
||||
return appState.cart.items.first?.storeId
|
||||
}
|
||||
}
|
||||
|
||||
enum CartAction {
|
||||
case add
|
||||
case set
|
||||
case openProductSheet
|
||||
case openPizzaSheet
|
||||
}
|
||||
205
Sources/PediFoods/Views/Main/StoreDetailView.swift
Normal file
@@ -0,0 +1,205 @@
|
||||
import SwiftUI
|
||||
#if canImport(LCEssentials) && os(iOS)
|
||||
import LCEssentials
|
||||
#endif
|
||||
#if canImport(UIKit)
|
||||
import UIKit
|
||||
#endif
|
||||
|
||||
struct StoreDetailView: View {
|
||||
let storeId: String
|
||||
let storeName: String
|
||||
let storeCoverURL: String?
|
||||
let storeLogoURL: String?
|
||||
let storeCategory: String?
|
||||
let storeRating: Double?
|
||||
let storeDistance: String?
|
||||
let storeDeliveryFee: Double?
|
||||
@Binding var appState: AppState
|
||||
|
||||
@Environment(\.dismiss) var dismiss
|
||||
|
||||
@State var isLoading = true
|
||||
@State var errorMessage: String? = nil
|
||||
@State var info: StoreInfoResult? = nil
|
||||
@State var categories: [StoreCatalogCategory] = []
|
||||
@State var selectedCategoryId: String? = nil
|
||||
@State var selectedProduct: StoreCatalogProduct? = nil
|
||||
@State var selectedPizzaCategoryId: String? = nil
|
||||
@State var showSwitchStoreAlert = false
|
||||
@State var pendingCartItem: CartItemState? = nil
|
||||
@State var pendingProductSheet: StoreCatalogProduct? = nil
|
||||
@State var pendingPizzaCategoryId: String? = nil
|
||||
@State var pendingCartAction: CartAction = .add
|
||||
@State var didLoad = false
|
||||
@State var categoryHeaderOffsets: [String: CGFloat] = [:]
|
||||
@State var isProgrammaticCategoryScroll = false
|
||||
@State var isFavoriteRequestInFlight = false
|
||||
@State var scrollOffset: CGFloat = 0
|
||||
|
||||
var isCategoryTabsPinned: Bool { scrollOffset >= topSectionHeight }
|
||||
var stretchAmount: CGFloat { max(0, -scrollOffset) }
|
||||
|
||||
let cardTopInset: CGFloat = 180
|
||||
let summaryCardBaseHeight: CGFloat = 212
|
||||
let closedBannerHeight: CGFloat = 44
|
||||
let coverVisibleUntilY: CGFloat = 253
|
||||
let storeLogoSize: CGFloat = 84
|
||||
|
||||
var safeAreaTop: CGFloat {
|
||||
#if canImport(UIKit)
|
||||
return UIDevice.appSafeAreaTop
|
||||
#else
|
||||
return 0
|
||||
#endif
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ScrollViewReader { proxy in
|
||||
ZStack(alignment: .top) {
|
||||
ScrollView(showsIndicators: false) {
|
||||
ScrollOffsetReader(offsetY: $scrollOffset)
|
||||
LazyVStack(spacing: 0) {
|
||||
topSection
|
||||
// Guaranteed-visible refresh feedback, right below
|
||||
// the hero — not relying on the native spinner's
|
||||
// position (unreliable here, see .refreshable note
|
||||
// below).
|
||||
if isLoading && categories.isEmpty == false {
|
||||
HStack(spacing: 8) {
|
||||
ProgressView()
|
||||
Text("Atualizando...")
|
||||
.font(AppTypography.caption)
|
||||
.foregroundStyle(AppColors.textMuted)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 10)
|
||||
}
|
||||
categoryTabs(proxy: proxy, isPinned: false)
|
||||
.opacity(isCategoryTabsPinned ? 0 : 1)
|
||||
sectionedProducts
|
||||
}
|
||||
}
|
||||
.coordinateSpace(name: StoreDetailScrollCoordinateSpace.name)
|
||||
.refreshable {
|
||||
// Run the actual load in its own unstructured Task and
|
||||
// await that, instead of awaiting loadStoreData directly
|
||||
// in this closure. SwiftUI can cancel .refreshable's own
|
||||
// wrapping Task (e.g. the gesture not fully "committing")
|
||||
// independent of whether the network call is still
|
||||
// legitimately in flight. Awaiting Task.value here
|
||||
// blocks until the detached load genuinely finishes
|
||||
// (success, error, or our own 20s ApiClient timeout),
|
||||
// so a premature refreshable-cancellation can no longer
|
||||
// silently swallow a real in-flight request.
|
||||
await Task { await loadStoreData(forceRefresh: true) }.value
|
||||
}
|
||||
// NOT .ignoresSafeArea here: combined with .refreshable on
|
||||
// the same view, it breaks the native pull-to-refresh
|
||||
// spinner's positioning (renders invisible/off-place) even
|
||||
// though the gesture still fires the closure. The hero
|
||||
// image and gradient above already bleed under the status
|
||||
// bar independently via their own .ignoresSafeArea calls.
|
||||
.background(AppColors.backgroundLight)
|
||||
|
||||
categoryTabs(proxy: proxy, isPinned: true)
|
||||
.opacity(isCategoryTabsPinned ? 1 : 0)
|
||||
.allowsHitTesting(isCategoryTabsPinned)
|
||||
.zIndex(10)
|
||||
}
|
||||
.ignoresSafeArea(edges: .top)
|
||||
.saturation(isStoreOpen ? 1 : 0)
|
||||
}
|
||||
.navigationBarBackButtonHidden(true)
|
||||
.appHiddenNavigationBar()
|
||||
.task {
|
||||
guard didLoad == false else { return }
|
||||
didLoad = true
|
||||
await loadStoreData(forceRefresh: false)
|
||||
}
|
||||
.sheet(item: $selectedProduct) { product in
|
||||
NavigationStack {
|
||||
ProductDetailSheet(
|
||||
product: product,
|
||||
imageURL: resolvedURL(product.image),
|
||||
storeId: storeId,
|
||||
currentQuantityForItemId: { itemId in
|
||||
currentQuantity(forCartItemId: itemId)
|
||||
},
|
||||
onAdd: { item in
|
||||
guard isStoreOpen else {
|
||||
SnackbarCenter.shared.show(title: "Loja fechada no momento.", style: .warning, icon: "moon.zzz.fill", duration: 2.0)
|
||||
return
|
||||
}
|
||||
requestSetCartItem(item)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
.sheet(
|
||||
isPresented: Binding(
|
||||
get: { selectedPizzaCategoryId != nil },
|
||||
set: { isPresented in
|
||||
if isPresented == false {
|
||||
selectedPizzaCategoryId = nil
|
||||
}
|
||||
}
|
||||
)
|
||||
) {
|
||||
if let category = categories.first(where: { $0.id == selectedPizzaCategoryId }) {
|
||||
NavigationStack {
|
||||
PizzaProductDetailSheet(
|
||||
category: category,
|
||||
storeId: storeId,
|
||||
resolveImageURL: { raw in resolvedURL(raw) },
|
||||
currentQuantityForItemId: { itemId in
|
||||
currentQuantity(forCartItemId: itemId)
|
||||
},
|
||||
onAdd: { item in
|
||||
guard isStoreOpen else {
|
||||
SnackbarCenter.shared.show(title: "Loja fechada no momento.", style: .warning, icon: "moon.zzz.fill", duration: 2.0)
|
||||
return
|
||||
}
|
||||
requestSetCartItem(item)
|
||||
}
|
||||
)
|
||||
}
|
||||
} else {
|
||||
ProgressView()
|
||||
}
|
||||
}
|
||||
.alert("Trocar de loja?", isPresented: $showSwitchStoreAlert) {
|
||||
Button("Cancelar", role: .cancel) {
|
||||
pendingCartItem = nil
|
||||
pendingProductSheet = nil
|
||||
pendingPizzaCategoryId = nil
|
||||
}
|
||||
Button("Limpar carrinho e adicionar", role: .destructive) {
|
||||
appState.cart.clear()
|
||||
switch pendingCartAction {
|
||||
case .add:
|
||||
guard let pendingCartItem else { return }
|
||||
applyAddToCart(pendingCartItem)
|
||||
case .set:
|
||||
guard let pendingCartItem else { return }
|
||||
applySetCartItem(pendingCartItem)
|
||||
case .openProductSheet:
|
||||
guard let pendingProductSheet else { return }
|
||||
selectedProduct = pendingProductSheet
|
||||
case .openPizzaSheet:
|
||||
guard let pendingPizzaCategoryId else { return }
|
||||
selectedPizzaCategoryId = pendingPizzaCategoryId
|
||||
}
|
||||
self.pendingCartItem = nil
|
||||
self.pendingProductSheet = nil
|
||||
self.pendingPizzaCategoryId = nil
|
||||
}
|
||||
} message: {
|
||||
Text("Seu carrinho tem itens de outra loja. Deseja limpar o carrinho atual para adicionar este produto?")
|
||||
}
|
||||
.onPreferenceChange(CategoryHeaderOffsetPreferenceKey.self) { offsets in
|
||||
categoryHeaderOffsets = offsets
|
||||
syncCategoryWithScroll()
|
||||
}
|
||||
}
|
||||
}
|
||||
357
Sources/PediFoods/Views/Main/UserProfileView.swift
Normal file
@@ -0,0 +1,357 @@
|
||||
import SwiftUI
|
||||
#if os(iOS)
|
||||
import PhotosUI
|
||||
import UIKit
|
||||
#endif
|
||||
|
||||
struct UserProfileView: View {
|
||||
@Binding var appState: AppState
|
||||
@Environment(\.dismiss) var dismiss
|
||||
|
||||
@State var name: String = ""
|
||||
@State var email: String = ""
|
||||
@State var phone: String = ""
|
||||
@State var cpf: String = ""
|
||||
@State var profilePicture: String = ""
|
||||
@State var isSaving = false
|
||||
|
||||
#if os(iOS)
|
||||
@State private var selectedPhotoItem: PhotosPickerItem?
|
||||
#endif
|
||||
|
||||
var body: some View {
|
||||
ScrollView(showsIndicators: false) {
|
||||
VStack(spacing: 22) {
|
||||
screenHeader
|
||||
avatarSection
|
||||
formSection
|
||||
saveButton
|
||||
}
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.top, 18)
|
||||
.padding(.bottom, UIDevice.bottomNotch + 24)
|
||||
}
|
||||
.background(AppColors.backgroundLight)
|
||||
.appHiddenNavigationBar()
|
||||
.navigationBarBackButtonHidden(true)
|
||||
.onAppear {
|
||||
hydrateFromAppState()
|
||||
}
|
||||
#if os(iOS)
|
||||
.onChange(of: selectedPhotoItem) { _, newItem in
|
||||
Task { await applySelectedPhoto(newItem) }
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
private var screenHeader: some View {
|
||||
ZStack {
|
||||
Text("Meu Perfil")
|
||||
.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 avatarSection: some View {
|
||||
VStack(spacing: 12) {
|
||||
Circle()
|
||||
.fill(AppColors.brandSoft)
|
||||
.frame(width: 110, height: 110)
|
||||
.overlay {
|
||||
if let imageSource = resolvedProfilePicture {
|
||||
AsyncStoreImage(imageURL: imageSource)
|
||||
.frame(width: 104, height: 104)
|
||||
.clipShape(Circle())
|
||||
} else {
|
||||
Text(initials)
|
||||
.font(.system(size: 32, weight: .bold))
|
||||
.foregroundStyle(AppColors.primary)
|
||||
}
|
||||
}
|
||||
|
||||
HStack(spacing: 10) {
|
||||
#if os(iOS)
|
||||
PhotosPicker(selection: $selectedPhotoItem, matching: .images, photoLibrary: .shared()) {
|
||||
Text("Trocar Foto")
|
||||
.font(AppTypography.caption)
|
||||
.foregroundStyle(AppColors.primary)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 8)
|
||||
.background(AppColors.surface)
|
||||
.clipShape(Capsule())
|
||||
}
|
||||
#endif
|
||||
Button("Remover") {
|
||||
profilePicture = ""
|
||||
}
|
||||
.font(AppTypography.caption)
|
||||
.foregroundStyle(AppColors.textMuted)
|
||||
.buttonStyle(.plain)
|
||||
.disabled(resolvedProfilePicture == nil)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
|
||||
private var formSection: some View {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
textFieldSection(title: "Nome", placeholder: "Seu nome completo", text: $name)
|
||||
|
||||
textFieldSection(title: "E-mail", placeholder: "seu@email.com", text: $email)
|
||||
.appNoAutoCap()
|
||||
|
||||
textFieldSection(title: "Telefone", placeholder: "(00) 00000-0000", text: $phone)
|
||||
.onChange(of: phone) { _, newValue in
|
||||
let masked = formatPhoneBR(displayPhoneDigits(newValue))
|
||||
if masked != newValue {
|
||||
phone = masked
|
||||
}
|
||||
}
|
||||
|
||||
textFieldSection(title: "CPF", placeholder: "000.000.000-00", text: $cpf)
|
||||
.keyboardType(.numberPad)
|
||||
.onChange(of: cpf) { _, newValue in
|
||||
let digits = newValue.filter(\.isNumber)
|
||||
let masked = formatCPF(digits)
|
||||
if masked != newValue { cpf = masked }
|
||||
}
|
||||
.appNoAutoCap()
|
||||
}
|
||||
.padding(16)
|
||||
.background(AppColors.surface)
|
||||
.clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous))
|
||||
}
|
||||
|
||||
private var saveButton: some View {
|
||||
Button(isSaving ? "Salvando..." : "Salvar Alterações") {
|
||||
Task { await saveProfile() }
|
||||
}
|
||||
.font(AppTypography.button)
|
||||
.foregroundStyle(AppColors.textInverse)
|
||||
.frame(maxWidth: .infinity, minHeight: 56)
|
||||
.background(AppColors.primary)
|
||||
.clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous))
|
||||
.buttonStyle(.plain)
|
||||
.disabled(isSaving || canSave == false)
|
||||
.opacity((isSaving || canSave == false) ? 0.6 : 1.0)
|
||||
}
|
||||
|
||||
private var resolvedProfilePicture: String? {
|
||||
let trimmed = profilePicture.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard trimmed.isEmpty == false else { return nil }
|
||||
return ImageSourceResolver.resolve(trimmed)
|
||||
}
|
||||
|
||||
private var initials: String {
|
||||
let parts = name
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
.split(separator: " ")
|
||||
.prefix(2)
|
||||
let letters = parts.compactMap { $0.first }.map(String.init).joined()
|
||||
return letters.isEmpty ? "PF" : letters.uppercased()
|
||||
}
|
||||
|
||||
private var canSave: Bool {
|
||||
let cleanName = name.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let cleanEmail = email.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let normalizedPhone = normalizePhoneNumberForAPI(phone)
|
||||
return cleanName.isEmpty == false
|
||||
&& cleanEmail.isEmpty == false
|
||||
&& cleanEmail.contains("@")
|
||||
&& normalizedPhone.isEmpty == false
|
||||
}
|
||||
|
||||
private func hydrateFromAppState() {
|
||||
name = appState.profile.name
|
||||
email = appState.profile.email
|
||||
phone = formatPhoneForDisplay(appState.profile.phone)
|
||||
profilePicture = appState.profile.profilePicture
|
||||
cpf = formatCPF(appState.profile.cpf.filter(\.isNumber))
|
||||
}
|
||||
|
||||
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))"
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func saveProfile() async {
|
||||
guard canSave else { return }
|
||||
|
||||
let cleanName = name.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let cleanEmail = email.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let normalizedPhone = normalizePhoneNumberForAPI(phone)
|
||||
let cleanPhoto = profilePicture.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let newPhoto = cleanPhoto.hasPrefix("data:") ? cleanPhoto : nil
|
||||
|
||||
isSaving = true
|
||||
defer { isSaving = false }
|
||||
|
||||
do {
|
||||
let response = try await ApiService().updateCustomerProfile(
|
||||
name: cleanName,
|
||||
email: cleanEmail,
|
||||
phoneNumber: normalizedPhone,
|
||||
profilePicture: newPhoto
|
||||
)
|
||||
|
||||
if response.error {
|
||||
SnackbarCenter.shared.show(
|
||||
title: response.message ?? "Não foi possível atualizar seu perfil.",
|
||||
style: .error,
|
||||
icon: "xmark.octagon.fill",
|
||||
duration: 3.0
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
appState.profile.name = cleanName
|
||||
appState.profile.email = cleanEmail
|
||||
appState.profile.phone = normalizedPhone
|
||||
if let pictureUrl = response.profilePictureUrl, pictureUrl.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false {
|
||||
appState.profile.profilePicture = ImageSourceResolver.resolve(pictureUrl) ?? pictureUrl
|
||||
} else if cleanPhoto.isEmpty == false {
|
||||
appState.profile.profilePicture = cleanPhoto
|
||||
}
|
||||
|
||||
let cleanCpf = cpf.filter(\.isNumber)
|
||||
if cleanCpf.count == 11 {
|
||||
guard isValidCPF(cleanCpf) else {
|
||||
SnackbarCenter.shared.show(
|
||||
title: "CPF inválido. Verifique e tente novamente.",
|
||||
style: .error,
|
||||
icon: "xmark.octagon.fill",
|
||||
duration: 3.0
|
||||
)
|
||||
return
|
||||
}
|
||||
let cpfResponse = try await ApiService().updateProfileCpf(cpf: cleanCpf)
|
||||
if cpfResponse.error {
|
||||
SnackbarCenter.shared.show(
|
||||
title: cpfResponse.message ?? "Não foi possível atualizar o CPF.",
|
||||
style: .error,
|
||||
icon: "xmark.octagon.fill",
|
||||
duration: 3.0
|
||||
)
|
||||
return
|
||||
}
|
||||
appState.profile.cpf = cleanCpf
|
||||
}
|
||||
SessionStateStore.setActiveUserKey(
|
||||
SessionStateStore.makeUserKey(profileId: appState.profile.id, email: appState.profile.email)
|
||||
)
|
||||
|
||||
SnackbarCenter.shared.show(
|
||||
title: "Perfil atualizado com sucesso.",
|
||||
style: .success,
|
||||
icon: "checkmark.circle.fill",
|
||||
duration: 2.0
|
||||
)
|
||||
dismiss()
|
||||
} catch {
|
||||
SnackbarCenter.shared.show(
|
||||
title: "Não foi possível atualizar seu perfil.",
|
||||
style: .error,
|
||||
icon: "xmark.octagon.fill",
|
||||
duration: 3.0
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func formatPhoneForDisplay(_ raw: String) -> String {
|
||||
let digits = displayPhoneDigits(raw)
|
||||
if digits.isEmpty { return "" }
|
||||
return formatPhoneBR(digits)
|
||||
}
|
||||
|
||||
private func displayPhoneDigits(_ raw: String) -> String {
|
||||
var digits = raw.filter(\.isNumber)
|
||||
if digits.hasPrefix("55"), digits.count > 11 {
|
||||
digits = String(digits.dropFirst(2))
|
||||
}
|
||||
return String(digits.prefix(11))
|
||||
}
|
||||
|
||||
private func textFieldSection(title: String, placeholder: String, text: Binding<String>) -> some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text(title)
|
||||
.font(AppTypography.caption)
|
||||
.foregroundStyle(AppColors.textMuted)
|
||||
|
||||
TextField(placeholder, text: text)
|
||||
.font(AppTypography.body)
|
||||
.foregroundStyle(AppColors.textPrimary)
|
||||
.padding(.horizontal, 12)
|
||||
.frame(height: 50)
|
||||
.background(AppColors.backgroundLight)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 14, style: .continuous)
|
||||
.stroke(AppColors.secondary.opacity(0.2), lineWidth: 1)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func isValidCPF(_ digits: String) -> Bool {
|
||||
guard digits.count == 11, digits.unicodeScalars.allSatisfy({ CharacterSet.decimalDigits.contains($0) }) else { return false }
|
||||
guard Set(digits).count > 1 else { return false }
|
||||
func checkDigit(_ d: String, _ length: Int) -> Bool {
|
||||
let sum = d.prefix(length).enumerated().reduce(0) { acc, pair in
|
||||
acc + (Int(String(pair.element)) ?? 0) * (length + 1 - pair.offset)
|
||||
}
|
||||
let rem = (sum * 10) % 11
|
||||
let expected = rem == 10 ? 0 : rem
|
||||
return Int(String(d[d.index(d.startIndex, offsetBy: length)])) == expected
|
||||
}
|
||||
return checkDigit(digits, 9) && checkDigit(digits, 10)
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
@MainActor
|
||||
private func applySelectedPhoto(_ item: PhotosPickerItem?) async {
|
||||
guard let item else { return }
|
||||
do {
|
||||
guard let data = try await item.loadTransferable(type: Data.self) else { return }
|
||||
guard let image = UIImage(data: data) else { return }
|
||||
let resized = resizedIfNeeded(image, maxSide: 600)
|
||||
guard let jpegData = resized.jpegData(compressionQuality: 0.8) else { return }
|
||||
profilePicture = "data:image/jpeg;base64,\(jpegData.base64EncodedString())"
|
||||
} catch {
|
||||
SnackbarCenter.shared.show(
|
||||
title: "Não foi possível carregar a foto selecionada.",
|
||||
style: .warning,
|
||||
icon: "photo",
|
||||
duration: 2.5
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func resizedIfNeeded(_ image: UIImage, maxSide: CGFloat) -> UIImage {
|
||||
let w = image.size.width
|
||||
let h = image.size.height
|
||||
guard w > maxSide || h > maxSide else { return image }
|
||||
let scale = maxSide / max(w, h)
|
||||
let newSize = CGSize(width: w * scale, height: h * scale)
|
||||
let renderer = UIGraphicsImageRenderer(size: newSize)
|
||||
return renderer.image { _ in
|
||||
image.draw(in: CGRect(origin: .zero, size: newSize))
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||