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? @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) { topHeader orderTitleSection statusBanner timelineSection placeholderCard if shouldShowReviewButton { reviewButton } else { contactButton } } .padding(.horizontal, 20) .padding(.top, 14) .padding(.bottom, UIDevice.bottomNotch + 45) } .background(AppColors.backgroundLight) .navigationTitle("Pedido \(displayOrderTitle)") .appInlineNavigationTitle() .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 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 { #if os(Android) SwiftUI.Image(trackingImageName, bundle: .module) .renderingMode(.original) .resizable() .scaledToFit() #else Image(trackingImageName) .renderingMode(.original) .resizable() .scaledToFit() #endif } else if hasPlaceholderProductImage { #if os(Android) SwiftUI.Image("placeholder-product", bundle: .module) .renderingMode(.original) .resizable() .scaledToFit() #else Image("placeholder-product") .renderingMode(.original) .resizable() .scaledToFit() #endif } 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() 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)") } }