import SwiftUI struct OrdersView: View { @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 selectedOrderRoute: OrderRouteContext? = nil var body: some View { ScrollView(showsIndicators: false) { VStack(alignment: .leading, spacing: 14) { 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(.bottom, UIDevice.bottomNotch + 18) } .background(AppColors.backgroundLight) .navigationTitle("Meus Pedidos") .navigationBarTitleDisplayMode(.inline) .task { await loadOrdersIfNeeded() await refreshStoreRatings() } .refreshable { await loadOrders(force: true) await refreshStoreRatings() } .navigationDestination(item: $selectedOrderRoute) { context in OrderEntryDestinationView( orderId: context.orderId, initialShortId: context.shortId, fallbackPaymentMethod: context.paymentMethod, fallbackTotal: context.total ) } } private func orderCard(_ order: AppOrderSummary) -> some View { let status = orderVisualStatus(for: order) let route = OrderRouteContext( orderId: trackingOrderId(for: order), shortId: order.shortId, paymentMethod: order.paymentMethod, total: order.total ) return VStack(alignment: .leading, spacing: 14) { HStack(spacing: 12) { AsyncStoreImage(imageURL: resolvedMediaURL(order.storeLogoURL)) .frame(width: 80, height: 80) .clipShape(RoundedRectangle(cornerRadius: 20, style: .continuous)) .background(AppColors.brandSoft, in: 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 = route } .font(AppTypography.heading3) .foregroundStyle(AppColors.textMuted) .lineLimit(1) .minimumScaleFactor(0.5) .buttonStyle(.plain) .layoutPriority(0) Spacer(minLength: 8) Button { if status.isInProgress { selectedOrderRoute = route 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) .layoutPriority(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] = [:] for store in storeList { guard let rating = store.rating, rating > 0 else { continue } let storeId = normalizedOrderId(store.id) if storeId.isEmpty == false { byId[storeId] = rating } let nameKey = normalizedStoreName(store.name) if nameKey.isEmpty == false { byName[nameKey] = rating } } storeRatingByStoreId = byId storeRatingByStoreName = byName } 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 resolvedMediaURL(_ raw: String?) -> String? { ImageSourceResolver.resolve(raw) } } struct OrderRouteContext: Identifiable, Hashable { var id: String { orderId } let orderId: String let shortId: String? let paymentMethod: String? let total: Double? } 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, 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?, 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.storeLogoURL = storeLogoURL 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 @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, 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 if let orderDetails { OrderDetailsView(order: orderDetails, orderId: orderId, initialShortId: initialShortId) } 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 shouldOpenOrderDetails(for: order) { orderDetails = order return } guard shouldOpenPaymentScreen(for: order) else { return } 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 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 == "CREDIT_CARD" || normalizedMethod == "DEBIT_CARD" { cardContext = CardPaymentContext( orderId: order.id, shortId: order.shortId ?? initialShortId, total: order.total ?? fallbackTotal ?? 0 ) return } } @MainActor func fetchOrderForRouting() async -> PublicOrderResult? { logger.info("OrderEntry fetch route orderId=\(orderId, privacy: .public)") 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, privacy: .public) status=\((result.status ?? "nil"), privacy: .public) paymentStatus=\((result.paymentStatus ?? "nil"), privacy: .public)") return result } logger.error("OrderEntry fetch route API error orderId=\(orderId, privacy: .public) message=\((response.message ?? "unknown"), privacy: .public)") } catch { logger.error("OrderEntry fetch route failure orderId=\(orderId, privacy: .public) error=\(error.localizedDescription, privacy: .public)") } 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() } }