import SwiftUI struct OrdersView: View { @State var isLoading = false @State var errorMessage: String? = nil @State var orders: [AppOrderSummary] = [] @State var hasLoadedOnce = false var body: some View { ScrollView(showsIndicators: false) { VStack(alignment: .leading, spacing: 14) { Text("Histórico de Pedidos") .font(AppTypography.heading1) .foregroundStyle(AppColors.textPrimary) .padding(.top, 6) 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 let trackingId = trackingOrderId(for: order) NavigationLink { OrderEntryDestinationView( orderId: trackingId, initialShortId: order.shortId, fallbackPaymentMethod: order.paymentMethod, fallbackTotal: order.total ) } label: { orderRow(order) } .buttonStyle(.plain) } } } .padding(.horizontal, 20) .padding(.bottom, UIDevice.bottomNotch + 18) } .background(AppColors.backgroundLight) .navigationTitle("Meus Pedidos") .navigationBarTitleDisplayMode(.inline) .task { await loadOrdersIfNeeded() } .refreshable { await loadOrders(force: true) } } private func orderRow(_ order: AppOrderSummary) -> some View { HStack(spacing: 12) { RoundedRectangle(cornerRadius: 10, style: .continuous) .fill(AppColors.brandSoft) .frame(width: 44, height: 44) .overlay( Image(systemName: "bag.fill") .foregroundStyle(AppColors.primary) ) VStack(alignment: .leading, spacing: 4) { Text("Pedido #\(order.shortId ?? order.id)") .font(AppTypography.heading3) .foregroundStyle(AppColors.textPrimary) Text(humanReadableStatus(for: order)) .font(AppTypography.caption) .foregroundStyle(AppColors.textMuted) } Spacer() VStack(alignment: .trailing, spacing: 4) { Text(formatCurrency(order.total ?? 0)) .font(AppTypography.heading3) .foregroundStyle(AppColors.textPrimary) if let createdAt = formatDate(order.createdAt) { Text(createdAt) .font(AppTypography.caption) .foregroundStyle(AppColors.textMuted) } } } .padding(14) .background(AppColors.surface) .clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous)) } private func humanReadableStatus(for order: AppOrderSummary) -> String { let explicitLabel = (order.statusLabel ?? "").trimmingCharacters(in: .whitespacesAndNewlines) if explicitLabel.isEmpty == false { return explicitLabel } let detailed = (order.statusDetailed ?? "").uppercased() switch detailed { case "PENDING_PAYMENT": return "Aguardando pagamento" case "PENDING_PREPARATION": return "Pendente de preparo" case "PREPARING": return "Em preparo" case "PENDING_DELIVERY": return "Pendente de entrega" case "READY_FOR_PICKUP": return "Pronto para retirada" case "IN_DELIVERY": return "Em rota" case "COMPLETED": return "Entregue" case "CANCELED": return "Cancelado" default: break } let raw = order.status ?? order.paymentStatus ?? "Pendente" let normalized = raw.uppercased() if normalized.contains("PAYMENT_PENDING") { return "Aguardando pagamento" } if normalized == "PENDING" || normalized == "ACCEPTED" { return "Pendente de preparo" } if normalized.contains("PREPARING") { return "Em preparo" } if normalized.contains("READY") { return "Pendente de entrega" } if normalized.contains("OUT_FOR_DELIVERY") || normalized.contains("DELIVERING") || normalized.contains("ROTA") { return "Em rota" } if normalized.contains("COMPLETED") || normalized.contains("DELIVERED") { return "Entregue" } if normalized.contains("CANCEL") { return "Cancelado" } return raw.capitalized } private func formatCurrency(_ value: Double) -> String { String(format: "R$ %.2f", value).replacingOccurrences(of: ".", with: ",") } private func formatDate(_ 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/MM 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 } @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) } // Use tracked-only list as bootstrap data only on first load. 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 } } // API order is authoritative for fallback ordering (usually newest first). 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") } } extension AppOrderSummary { static func fromTracked(_ tracked: PublicOrderResult) -> AppOrderSummary { AppOrderSummary( id: tracked.id, orderId: tracked.realId ?? tracked.id, realId: tracked.realId, 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, createdAt: tracked.createdAt, updatedAt: tracked.updatedAt ) } init( id: String, orderId: String?, realId: String?, shortId: String?, total: Double?, status: String?, statusDetailed: String?, statusLabel: String?, nextAction: String?, paymentStatus: String?, paymentMethod: String?, deliveryType: String?, storeName: String?, createdAt: String?, updatedAt: String? ) { self.id = id self.orderId = orderId self.realId = realId 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.createdAt = createdAt self.updatedAt = updatedAt } } struct OrderEntryDestinationView: View { let orderId: String let initialShortId: String? let fallbackPaymentMethod: String? let fallbackTotal: Double? @State var isResolvingRoute = true @State var didResolve = false @State var pixContext: PixPaymentContext? = nil @State var cardContext: CardPaymentContext? = nil @State var orderTrackingContext: OrderTrackingContext? = 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, onPaymentConfirmed: { orderTrackingContext = OrderTrackingContext(orderId: pixContext.orderId, shortId: pixContext.shortId) }, onOpenTracking: { orderTrackingContext = OrderTrackingContext(orderId: pixContext.orderId, shortId: pixContext.shortId) } ) } else if let cardContext { PaymentCardView( context: cardContext, onPaymentConfirmed: { orderTrackingContext = OrderTrackingContext(orderId: cardContext.orderId, shortId: cardContext.shortId) }, onOpenTracking: { orderTrackingContext = OrderTrackingContext(orderId: cardContext.orderId, shortId: cardContext.shortId) } ) } 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 } guard order.isPaymentConfirmed == false else { return } guard isOnlinePaymentMethod(order) else { return } let normalizedMethod = normalizePaymentMethod(order) let normalizedStatus = normalize(order.paymentStatus) if normalizedStatus.contains("PENDING") == false && normalizedStatus.isEmpty == false { return } 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 pixContext = PixPaymentContext( id: order.id, orderId: order.id, shortId: order.shortId ?? initialShortId, copyPaste: copyPaste?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false ? (copyPaste ?? "") : "Código PIX indisponível no momento. Aguarde e tente novamente.", qrCodeImageBase64: qrCodeImage, expirationDate: expirationDate ) return } if normalizedMethod.contains("CREDIT") || normalizedMethod.contains("CARD") { cardContext = CardPaymentContext( orderId: order.id, shortId: order.shortId ?? initialShortId, total: order.total ?? fallbackTotal ?? 0 ) } } @MainActor func fetchOrderForRouting() async -> PublicOrderResult? { do { let response = try await ApiService().publicOrder(orderId: orderId) if response.error == false, let result = response.result { SessionStateStore.saveTrackedOrder(result) return result } } catch { // Fallback to local cache when remote call fails. } return SessionStateStore.loadTrackedOrder(orderId: orderId) } func isOnlinePaymentMethod(_ order: PublicOrderResult) -> Bool { let method = normalizePaymentMethod(order) return method.contains("PIX") || method.contains("CREDIT") || method.contains("CARD") } 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() } }