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

@@ -4,6 +4,7 @@ struct OrdersView: View {
@State var isLoading = false
@State var errorMessage: String? = nil
@State var orders: [AppOrderSummary] = []
@State var hasLoadedOnce = false
var body: some View {
ScrollView(showsIndicators: false) {
@@ -31,8 +32,14 @@ struct OrdersView: View {
.padding(.top, 24)
} else {
ForEach(orders) { order in
let trackingId = trackingOrderId(for: order)
NavigationLink {
OrderTrackingView(orderId: order.id, initialShortId: order.shortId)
OrderEntryDestinationView(
orderId: trackingId,
initialShortId: order.shortId,
fallbackPaymentMethod: order.paymentMethod,
fallbackTotal: order.total
)
} label: {
orderRow(order)
}
@@ -47,7 +54,10 @@ struct OrdersView: View {
.navigationTitle("Meus Pedidos")
.navigationBarTitleDisplayMode(.inline)
.task {
await loadOrders()
await loadOrdersIfNeeded()
}
.refreshable {
await loadOrders(force: true)
}
}
@@ -66,7 +76,7 @@ struct OrdersView: View {
.font(AppTypography.heading3)
.foregroundStyle(AppColors.textPrimary)
Text(humanReadableStatus(order.status ?? order.paymentStatus ?? "Pendente"))
Text(humanReadableStatus(for: order))
.font(AppTypography.caption)
.foregroundStyle(AppColors.textMuted)
}
@@ -89,11 +99,41 @@ struct OrdersView: View {
.clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous))
}
private func humanReadableStatus(_ raw: String) -> String {
private func humanReadableStatus(for order: AppOrderSummary) -> String {
let explicitLabel = (order.statusLabel ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
if explicitLabel.isEmpty == false {
return explicitLabel
}
let detailed = (order.statusDetailed ?? "").uppercased()
switch detailed {
case "PENDING_PAYMENT":
return "Aguardando pagamento"
case "PENDING_PREPARATION":
return "Pendente de preparo"
case "PREPARING":
return "Em preparo"
case "PENDING_DELIVERY":
return "Pendente de entrega"
case "READY_FOR_PICKUP":
return "Pronto para retirada"
case "IN_DELIVERY":
return "Em rota"
case "COMPLETED":
return "Entregue"
case "CANCELED":
return "Cancelado"
default:
break
}
let raw = order.status ?? order.paymentStatus ?? "Pendente"
let normalized = raw.uppercased()
if normalized.contains("PAYMENT_PENDING") || normalized == "PENDING" { return "Aguardando pagamento" }
if normalized.contains("CONFIRMED") { return "Confirmado" }
if normalized.contains("OUT_FOR_DELIVERY") || normalized.contains("ROTA") { return "Em rota" }
if normalized.contains("PAYMENT_PENDING") { return "Aguardando pagamento" }
if normalized == "PENDING" || normalized == "ACCEPTED" { return "Pendente de preparo" }
if normalized.contains("PREPARING") { return "Em preparo" }
if normalized.contains("READY") { return "Pendente de entrega" }
if normalized.contains("OUT_FOR_DELIVERY") || normalized.contains("DELIVERING") || normalized.contains("ROTA") { return "Em rota" }
if normalized.contains("COMPLETED") || normalized.contains("DELIVERED") { return "Entregue" }
if normalized.contains("CANCEL") { return "Cancelado" }
return raw.capitalized
@@ -120,56 +160,192 @@ struct OrdersView: View {
return formatter.string(from: date)
}
private func trackingOrderId(for order: AppOrderSummary) -> String {
let orderCandidate = (order.orderId ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
if orderCandidate.isEmpty == false {
return orderCandidate
}
let candidate = (order.realId ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
if candidate.isEmpty == false {
return candidate
}
return order.id
}
@MainActor
private func loadOrders() async {
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 {
let mapped = cachedTracked.map {
trackedMapped = cachedTracked.map {
AppOrderSummary.fromTracked($0)
}
orders = mergeOrders(apiOrders: orders, trackedOrders: mapped)
// Use tracked-only list as bootstrap data only on first load.
if hasLoadedOnce == false, previousOrders.isEmpty {
orders = mergeOrders(apiOrders: [], trackedOrders: trackedMapped)
}
}
do {
let response = try await ApiService().listOrders()
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: orders)
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] = [:]
for item in trackedOrders { map[item.id] = item }
for item in apiOrders { map[item.id] = item }
var sourceRank: [String: Int] = [:]
for (index, item) in trackedOrders.enumerated() {
let key = identityKey(for: item)
map[key] = item
if sourceRank[key] == nil {
sourceRank[key] = 10_000 + index
}
}
// API order is authoritative for fallback ordering (usually newest first).
for (index, item) in apiOrders.enumerated() {
let key = identityKey(for: item)
map[key] = item
sourceRank[key] = index
}
return map.values.sorted { lhs, rhs in
let left = lhs.updatedAt ?? lhs.createdAt ?? ""
let right = rhs.updatedAt ?? rhs.createdAt ?? ""
return left > right
let leftDate = orderDateSortValue(lhs)
let rightDate = orderDateSortValue(rhs)
if leftDate != rightDate {
return leftDate > rightDate
}
let leftRank = sourceRank[identityKey(for: lhs)] ?? Int.max
let rightRank = sourceRank[identityKey(for: rhs)] ?? Int.max
if leftRank != rightRank {
return leftRank < rightRank
}
let leftNumericId = Int(lhs.id)
let rightNumericId = Int(rhs.id)
if let leftNumericId, let rightNumericId, leftNumericId != rightNumericId {
return leftNumericId > rightNumericId
}
return lhs.id.localizedCompare(rhs.id) == .orderedDescending
}
}
private func identityKey(for order: AppOrderSummary) -> String {
let raw = trackingOrderId(for: order)
return raw.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
}
private func orderDateSortValue(_ order: AppOrderSummary) -> Date {
parseDateForSort(order.updatedAt)
?? parseDateForSort(order.createdAt)
?? .distantPast
}
private func parseDateForSort(_ rawValue: String?) -> Date? {
guard let rawValue else { return nil }
let value = rawValue.trimmingCharacters(in: .whitespacesAndNewlines)
if value.isEmpty { return nil }
let iso = ISO8601DateFormatter()
iso.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
if let date = iso.date(from: value) { return date }
iso.formatOptions = [.withInternetDateTime]
if let date = iso.date(from: value) { return date }
let formatter = DateFormatter()
formatter.locale = Locale(identifier: "pt_BR")
let formats = [
"yyyy-MM-dd'T'HH:mm:ss.SSSXXXXX",
"yyyy-MM-dd'T'HH:mm:ssXXXXX",
"yyyy-MM-dd HH:mm:ss",
"yyyy-MM-dd'T'HH:mm:ss",
"yyyy-MM-dd'T'HH:mm:ss.SSSZ",
"yyyy-MM-dd HH:mm:ss Z",
"dd/MM/yyyy HH:mm:ss",
"dd/MM/yyyy HH:mm",
"dd/MM/yyyy"
]
for format in formats {
formatter.dateFormat = format
if let date = formatter.date(from: value) {
return date
}
}
return nil
}
private func isCancelledRequest(_ error: Error) -> Bool {
if error is CancellationError {
return true
}
if let networkError = error as? NetworkError,
case .transportError(let message) = networkError {
let normalized = message.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
return normalized.contains("cancel")
}
if let networkError = error as? NetworkError,
case .cancelled = networkError {
return true
}
return error.localizedDescription.lowercased().contains("cancel")
}
}
extension AppOrderSummary {
static func fromTracked(_ tracked: PublicOrderResult) -> AppOrderSummary {
AppOrderSummary(
id: tracked.id,
orderId: tracked.realId ?? tracked.id,
realId: tracked.realId,
shortId: tracked.shortId,
total: tracked.total,
status: tracked.status,
statusDetailed: nil,
statusLabel: nil,
nextAction: nil,
paymentStatus: tracked.paymentStatus,
paymentMethod: tracked.paymentMethod,
deliveryType: tracked.deliveryType,
@@ -181,9 +357,14 @@ extension AppOrderSummary {
init(
id: String,
orderId: String?,
realId: String?,
shortId: String?,
total: Double?,
status: String?,
statusDetailed: String?,
statusLabel: String?,
nextAction: String?,
paymentStatus: String?,
paymentMethod: String?,
deliveryType: String?,
@@ -192,9 +373,14 @@ extension AppOrderSummary {
updatedAt: String?
) {
self.id = id
self.orderId = orderId
self.realId = realId
self.shortId = shortId
self.total = total
self.status = status
self.statusDetailed = statusDetailed
self.statusLabel = statusLabel
self.nextAction = nextAction
self.paymentStatus = paymentStatus
self.paymentMethod = paymentMethod
self.deliveryType = deliveryType
@@ -203,3 +389,150 @@ extension AppOrderSummary {
self.updatedAt = updatedAt
}
}
struct OrderEntryDestinationView: View {
let orderId: String
let initialShortId: String?
let fallbackPaymentMethod: String?
let fallbackTotal: Double?
@State var isResolvingRoute = true
@State var didResolve = false
@State var pixContext: PixPaymentContext? = nil
@State var cardContext: CardPaymentContext? = nil
@State var orderTrackingContext: OrderTrackingContext? = nil
var body: some View {
Group {
if isResolvingRoute {
VStack(spacing: 10) {
ProgressView()
Text("Carregando pedido...")
.font(AppTypography.body)
.foregroundStyle(AppColors.textMuted)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(AppColors.backgroundLight)
} else if let pixContext {
PaymentPixView(
context: pixContext,
onPaymentConfirmed: {
orderTrackingContext = OrderTrackingContext(orderId: pixContext.orderId, shortId: pixContext.shortId)
},
onOpenTracking: {
orderTrackingContext = OrderTrackingContext(orderId: pixContext.orderId, shortId: pixContext.shortId)
}
)
} else if let cardContext {
PaymentCardView(
context: cardContext,
onPaymentConfirmed: {
orderTrackingContext = OrderTrackingContext(orderId: cardContext.orderId, shortId: cardContext.shortId)
},
onOpenTracking: {
orderTrackingContext = OrderTrackingContext(orderId: cardContext.orderId, shortId: cardContext.shortId)
}
)
} else {
OrderTrackingView(orderId: orderId, initialShortId: initialShortId)
}
}
.navigationDestination(item: $orderTrackingContext) { context in
OrderTrackingView(orderId: context.orderId, initialShortId: context.shortId)
}
.task {
guard didResolve == false else { return }
didResolve = true
await resolveRoute()
}
}
@MainActor
func resolveRoute() async {
defer { isResolvingRoute = false }
let order = await fetchOrderForRouting()
guard let order else { return }
guard order.isPaymentConfirmed == false else { return }
guard isOnlinePaymentMethod(order) else { return }
let normalizedMethod = normalizePaymentMethod(order)
let normalizedStatus = normalize(order.paymentStatus)
if normalizedStatus.contains("PENDING") == false && normalizedStatus.isEmpty == false {
return
}
if normalizedMethod.contains("PIX") {
let pixFromPayment = order.payment?.pix
let pixFromPayload = order.paymentPayload
let copyPaste = (pixFromPayment?.copyPaste?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false)
? pixFromPayment?.copyPaste
: pixFromPayload?.copyPaste
let qrCodeImage = (pixFromPayment?.qrCodeImage?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false)
? pixFromPayment?.qrCodeImage
: pixFromPayload?.qrCodeImage
let expirationDate = (pixFromPayment?.expirationDate?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false)
? pixFromPayment?.expirationDate
: pixFromPayload?.expirationDate
pixContext = PixPaymentContext(
id: order.id,
orderId: order.id,
shortId: order.shortId ?? initialShortId,
copyPaste: copyPaste?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false
? (copyPaste ?? "")
: "Código PIX indisponível no momento. Aguarde e tente novamente.",
qrCodeImageBase64: qrCodeImage,
expirationDate: expirationDate
)
return
}
if normalizedMethod.contains("CREDIT") || normalizedMethod.contains("CARD") {
cardContext = CardPaymentContext(
orderId: order.id,
shortId: order.shortId ?? initialShortId,
total: order.total ?? fallbackTotal ?? 0
)
}
}
@MainActor
func fetchOrderForRouting() async -> PublicOrderResult? {
do {
let response = try await ApiService().publicOrder(orderId: orderId)
if response.error == false, let result = response.result {
SessionStateStore.saveTrackedOrder(result)
return result
}
} catch {
// Fallback to local cache when remote call fails.
}
return SessionStateStore.loadTrackedOrder(orderId: orderId)
}
func isOnlinePaymentMethod(_ order: PublicOrderResult) -> Bool {
let method = normalizePaymentMethod(order)
return method.contains("PIX") || method.contains("CREDIT") || method.contains("CARD")
}
func normalizePaymentMethod(_ order: PublicOrderResult) -> String {
let first = normalize(order.paymentMethodCode)
if first.isEmpty == false {
return first
}
let second = normalize(order.paymentMethod)
if second.isEmpty == false {
return second
}
return normalize(fallbackPaymentMethod)
}
func normalize(_ value: String?) -> String {
(value ?? "")
.trimmingCharacters(in: .whitespacesAndNewlines)
.uppercased()
}
}