migration
This commit is contained in:
919
PediFoods/Views/Main/OrderTrackingView.swift
Normal file
919
PediFoods/Views/Main/OrderTrackingView.swift
Normal file
@@ -0,0 +1,919 @@
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
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?
|
||||
@State var showPushOptInAlert = false
|
||||
|
||||
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)
|
||||
}
|
||||
.alert("Ative as notificações", isPresented: $showPushOptInAlert) {
|
||||
Button("Agora não", role: .cancel) {}
|
||||
Button("Ativar") { Task { await enablePushNotifications() } }
|
||||
} message: {
|
||||
Text("Ative as notificações para acompanhar em tempo real as atualizações do seu pedido.")
|
||||
}
|
||||
.task {
|
||||
await loadInitialOrder()
|
||||
await maybePromptPushOptIn()
|
||||
tracker.onOrderUpdated = { updated in
|
||||
order = updated
|
||||
if let inlinePhone = updated.storePhone, inlinePhone.isEmpty == false {
|
||||
storeContactPhone = inlinePhone
|
||||
}
|
||||
isLoading = false
|
||||
errorMessage = nil
|
||||
Task { await maybePromptPushOptIn() }
|
||||
}
|
||||
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 {
|
||||
UIImage(named: name) != nil
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
/// Touchpoint 2 of docs/api/push-notifications-integration-guide.md §2b —
|
||||
/// last practical moment to recover an opted-out user before order-status
|
||||
/// push (§6, `type: "order_status"`) goes silent for them for this order.
|
||||
@MainActor
|
||||
private func maybePromptPushOptIn() async {
|
||||
guard showPushOptInAlert == false, isWaitingPayment == false, isCanceled == false else { return }
|
||||
guard SessionStateStore.shouldPromptPushOptIn() else { return }
|
||||
|
||||
let osAuthorized = await PushNotificationCoordinator.shared.currentAuthorizationState() == .authorized
|
||||
var serverEnabled = false
|
||||
if let profileResponse = try? await ApiService().profile(), profileResponse.error == false {
|
||||
serverEnabled = profileResponse.result?.notificationsEnabled ?? false
|
||||
}
|
||||
guard osAuthorized == false || serverEnabled == false else { return }
|
||||
|
||||
SessionStateStore.recordPushOptInPrompted()
|
||||
showPushOptInAlert = true
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func enablePushNotifications() async {
|
||||
let profile = await PushNotificationCoordinator.shared.enableNotifications()
|
||||
if profile?.notificationsEnabled != true {
|
||||
SnackbarCenter.shared.show(
|
||||
title: "Ative notificações nos Ajustes do iPhone para acompanhar seu pedido.",
|
||||
style: .warning,
|
||||
icon: "bell.slash.fill",
|
||||
duration: 3.5
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@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)")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user