payment and list

This commit is contained in:
Daniel Arantes Loverde
2026-02-24 10:19:47 -03:00
parent 3a3dc7217b
commit ca83a275ac
51 changed files with 1645 additions and 256 deletions

View File

@@ -1,5 +1,14 @@
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?
@@ -8,38 +17,30 @@ struct OrderTrackingView: View {
@State var errorMessage: String? = nil
@State var order: PublicOrderResult? = nil
@State var tracker = OrderRealtimeTracker()
@State var showCancellationReason = false
var body: some View {
ScrollView(showsIndicators: false) {
VStack(spacing: 16) {
headerCard
if isLoading {
ProgressView()
.padding(.top, 20)
}
if let errorMessage, errorMessage.isEmpty == false {
Text(errorMessage)
.font(AppTypography.body)
.foregroundStyle(Color.red)
.multilineTextAlignment(.center)
.padding(.horizontal, 20)
}
if let order {
timelineCard(order)
}
footerPlaceholderCard
topHeader
orderTitleSection
statusBanner
timelineSection
placeholderCard
contactButton
}
.padding(.horizontal, 20)
.padding(.top, 14)
.padding(.bottom, UIDevice.bottomNotch + 24)
.padding(.bottom, UIDevice.bottomNotch + 45)
}
.background(AppColors.backgroundLight)
.navigationTitle("Pedido \(displayOrderTitle)")
.navigationBarTitleDisplayMode(.inline)
.alert("Motivo do cancelamento", isPresented: $showCancellationReason) {
Button("Fechar", role: .cancel) {}
} message: {
Text(cancellationReasonText)
}
.task {
await loadInitialOrder()
tracker.onOrderUpdated = { updated in
@@ -54,67 +55,103 @@ struct OrderTrackingView: View {
}
}
private var displayOrderTitle: String {
if let short = order?.shortId, short.isEmpty == false { return "#\(short)" }
if let initialShortId, initialShortId.isEmpty == false { return "#\(initialShortId)" }
return "#\(orderId.prefix(6))"
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 headerCard: some View {
private var orderTitleSection: some View {
VStack(alignment: .leading, spacing: 6) {
Text(statusTitle)
Text(order?.storeName?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false ? (order?.storeName ?? "Seu pedido") : "Seu pedido")
.font(AppTypography.heading1)
.foregroundStyle(AppColors.textPrimary)
Text("Pedido ID #\(order?.shortId ?? initialShortId ?? orderId)")
Text("Pedido #\(displayOrderTitle)")
.font(AppTypography.body)
.foregroundStyle(AppColors.textMuted)
if let order, order.isInDeliveryRoute, let otp = order.displayOtpCode {
Text("Seu código do pedido: \(otp) - Informe esse número ao motoboy")
.font(AppTypography.body)
.foregroundStyle(AppColors.textPrimary)
.multilineTextAlignment(.leading)
.padding(.top, 2)
}
HStack {
Spacer()
Text(statusPill)
.font(.system(size: 13, weight: .bold))
.foregroundStyle(AppColors.primary)
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(AppColors.brandSoft)
.clipShape(Capsule())
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(16)
.background(AppColors.surface)
.clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous))
}
private func timelineCard(_ order: PublicOrderResult) -> some View {
let events: [PublicOrderTimelineEvent] = {
if order.timeline.isEmpty == false { return order.timeline }
return [
PublicOrderTimelineEvent(
status: order.status ?? order.paymentStatus ?? "PENDING",
message: nil,
time: order.updatedAt ?? order.createdAt
@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"
)
}
}
return VStack(alignment: .leading, spacing: 12) {
Text("Acompanhamento")
private var timelineSection: some View {
VStack(alignment: .leading, spacing: 14) {
Text("Progresso do Pedido")
.font(AppTypography.heading2)
.foregroundStyle(AppColors.textPrimary)
VStack(alignment: .leading, spacing: 0) {
ForEach(Array(events.enumerated()), id: \.offset) { index, event in
timelineRow(event: event, isLast: index == events.count - 1)
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)
}
}
}
}
@@ -124,112 +161,473 @@ struct OrderTrackingView: View {
.clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous))
}
private func timelineRow(event: PublicOrderTimelineEvent, isLast: Bool) -> some View {
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") {}
.font(AppTypography.heading2)
.foregroundStyle(AppColors.textPrimary)
.frame(maxWidth: .infinity, minHeight: 56)
.background(AppColors.surface)
.clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous))
.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(AppColors.tertiary)
.fill(stepDotColor(step))
.frame(width: 20, height: 20)
.overlay(
Image(systemName: "checkmark")
.font(.system(size: 11, weight: .bold))
.foregroundStyle(.white)
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(AppColors.tertiary.opacity(0.45))
.frame(width: 2, height: 30)
.fill(stepLineColor(step))
.frame(width: 2, height: 36)
}
}
VStack(alignment: .leading, spacing: 2) {
Text(humanReadableStatus(event.status ?? event.message ?? "Atualizado"))
.font(AppTypography.heading3)
.foregroundStyle(AppColors.textPrimary)
Text(formatTime(event.time) ?? "")
.font(AppTypography.caption)
.foregroundStyle(AppColors.textMuted)
}
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 var footerPlaceholderCard: some View {
VStack(spacing: 10) {
RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous)
.fill(Color.black.opacity(0.9))
.frame(height: 180)
.overlay(
Image(systemName: "shippingbox.fill")
.font(.system(size: 56, weight: .bold))
.foregroundStyle(AppColors.tertiary)
)
Text("Acompanhe seu pedido em tempo real")
.font(AppTypography.body)
.foregroundStyle(AppColors.textMuted)
private func stepDotColor(_ step: TrackingStep) -> Color {
if isCanceled {
return Color(hex: "#C5CBD4")
}
.frame(maxWidth: .infinity)
.padding(16)
.background(AppColors.surface)
.clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous))
if isWaitingPayment && step.id == "paid" {
return Color(hex: "#F59E0B")
}
if step.isCompleted || step.isActive {
return AppColors.primary
}
return Color(hex: "#E5E7EB")
}
private var statusTitle: String {
humanReadableStatus(order?.status ?? "Aguardando")
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 var statusPill: String {
let value = order?.paymentStatus ?? order?.status ?? "PENDING"
return humanReadableStatus(value)
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 humanReadableStatus(_ raw: String) -> String {
let normalized = raw.uppercased()
if normalized.contains("PAYMENT_PENDING") || normalized == "PENDING" {
return "Aguardando pagamento"
private func stepSubtitleColor(_ step: TrackingStep) -> Color {
if isCanceled { return Color(hex: "#9CA3AF") }
if isWaitingPayment && step.id == "paid" {
return Color(hex: "#A16207")
}
if normalized.contains("CONFIRMED") {
return "Pagamento confirmado"
if step.isCompleted || step.isActive {
return AppColors.primary
}
if normalized.contains("PREPAR") {
return "Em preparo"
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
)
}
if normalized.contains("OUT_FOR_DELIVERY") || normalized.contains("EM_ROTA") || normalized.contains("ROTA") {
return "Em rota de entrega"
}
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 normalized.contains("COMPLETED") || normalized.contains("DELIVERED") {
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 label.isEmpty == false {
return label
}
return fallback
}
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 normalized.contains("CANCEL") {
return "Pedido cancelado"
if isOnlinePaymentMethod {
return "Pagamento confirmado"
}
return raw.capitalized
return "Pedido confirmado"
}
private func formatTime(_ isoValue: String?) -> String? {
guard let isoValue, isoValue.isEmpty == false else { return nil }
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 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 isPickup ? "tracking-ready" : "tracking-preparing"
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: Date? = iso.date(from: isoValue)
var date = iso.date(from: value)
if date == nil {
iso.formatOptions = [.withInternetDateTime]
date = iso.date(from: isoValue)
date = iso.date(from: value)
}
if date == nil {
let fallback = DateFormatter()
fallback.locale = Locale(identifier: "pt_BR")
fallback.dateFormat = "yyyy-MM-dd HH:mm:ss"
date = fallback.date(from: isoValue)
}
guard let date else { return nil }
let formatter = DateFormatter()
formatter.locale = Locale(identifier: "pt_BR")
formatter.dateFormat = "HH:mm"