migration
This commit is contained in:
892
PediFoods/Views/Main/OrdersView.swift
Normal file
892
PediFoods/Views/Main/OrdersView.swift
Normal file
@@ -0,0 +1,892 @@
|
||||
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()
|
||||
}
|
||||
.onAppear {
|
||||
if let pending = appState.pendingOrderDeepLink {
|
||||
appState.pendingOrderDeepLink = nil
|
||||
selectedOrderRoute = pending
|
||||
}
|
||||
}
|
||||
.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()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user