feat(orders-ui): alinhar lista/detalhe ao layout e corrigir build

This commit is contained in:
Daniel Arantes Loverde
2026-03-05 10:23:04 -03:00
parent 1407f0b9de
commit 83882538c2
3 changed files with 259 additions and 92 deletions

View File

@@ -81,6 +81,9 @@ struct PublicOrderResult: Codable, Identifiable {
let nextAction: String? let nextAction: String?
let deliveryType: String? let deliveryType: String?
let deliveryTypeLabel: String? let deliveryTypeLabel: String?
let subtotal: Double?
let deliveryFee: Double?
let discount: Double?
let total: Double? let total: Double?
let storeName: String? let storeName: String?
let storeLogoURL: String? let storeLogoURL: String?
@@ -111,6 +114,15 @@ struct PublicOrderResult: Codable, Identifiable {
case nextAction case nextAction
case deliveryType case deliveryType
case deliveryTypeLabel case deliveryTypeLabel
case subtotal
case subTotal
case itemsTotal
case deliveryFee
case delivery_fee
case fee
case discount
case desconto
case couponDiscount
case total case total
case storeName case storeName
case storeLogo case storeLogo
@@ -148,6 +160,9 @@ struct PublicOrderResult: Codable, Identifiable {
nextAction: String? = nil, nextAction: String? = nil,
deliveryType: String? = nil, deliveryType: String? = nil,
deliveryTypeLabel: String? = nil, deliveryTypeLabel: String? = nil,
subtotal: Double? = nil,
deliveryFee: Double? = nil,
discount: Double? = nil,
total: Double? = nil, total: Double? = nil,
storeName: String? = nil, storeName: String? = nil,
storeLogoURL: String? = nil, storeLogoURL: String? = nil,
@@ -176,6 +191,9 @@ struct PublicOrderResult: Codable, Identifiable {
self.nextAction = nextAction self.nextAction = nextAction
self.deliveryType = deliveryType self.deliveryType = deliveryType
self.deliveryTypeLabel = deliveryTypeLabel self.deliveryTypeLabel = deliveryTypeLabel
self.subtotal = subtotal
self.deliveryFee = deliveryFee
self.discount = discount
self.total = total self.total = total
self.storeName = storeName self.storeName = storeName
self.storeLogoURL = storeLogoURL self.storeLogoURL = storeLogoURL
@@ -217,6 +235,9 @@ struct PublicOrderResult: Codable, Identifiable {
} }
deliveryType = ApiService.decodeFlexibleString(from: container, keys: [.deliveryType]) deliveryType = ApiService.decodeFlexibleString(from: container, keys: [.deliveryType])
deliveryTypeLabel = ApiService.decodeFlexibleString(from: container, keys: [.deliveryTypeLabel]) deliveryTypeLabel = ApiService.decodeFlexibleString(from: container, keys: [.deliveryTypeLabel])
subtotal = ApiService.decodeFlexibleDouble(from: container, keys: [.subtotal, .subTotal, .itemsTotal])
deliveryFee = ApiService.decodeFlexibleDouble(from: container, keys: [.deliveryFee, .delivery_fee, .fee])
discount = ApiService.decodeFlexibleDouble(from: container, keys: [.discount, .desconto, .couponDiscount])
total = ApiService.decodeFlexibleDouble(from: container, keys: [.total]) total = ApiService.decodeFlexibleDouble(from: container, keys: [.total])
storeName = ApiService.decodeFlexibleString(from: container, keys: [.storeName]) storeName = ApiService.decodeFlexibleString(from: container, keys: [.storeName])
storeLogoURL = ApiService.decodeFlexibleString(from: container, keys: [.storeLogo, .store_logo, .logo]) storeLogoURL = ApiService.decodeFlexibleString(from: container, keys: [.storeLogo, .store_logo, .logo])
@@ -253,6 +274,9 @@ struct PublicOrderResult: Codable, Identifiable {
try container.encodeIfPresent(nextAction, forKey: .nextAction) try container.encodeIfPresent(nextAction, forKey: .nextAction)
try container.encodeIfPresent(deliveryType, forKey: .deliveryType) try container.encodeIfPresent(deliveryType, forKey: .deliveryType)
try container.encodeIfPresent(deliveryTypeLabel, forKey: .deliveryTypeLabel) try container.encodeIfPresent(deliveryTypeLabel, forKey: .deliveryTypeLabel)
try container.encodeIfPresent(subtotal, forKey: .subtotal)
try container.encodeIfPresent(deliveryFee, forKey: .deliveryFee)
try container.encodeIfPresent(discount, forKey: .discount)
try container.encodeIfPresent(total, forKey: .total) try container.encodeIfPresent(total, forKey: .total)
try container.encodeIfPresent(storeName, forKey: .storeName) try container.encodeIfPresent(storeName, forKey: .storeName)
try container.encodeIfPresent(createdAt, forKey: .createdAt) try container.encodeIfPresent(createdAt, forKey: .createdAt)

View File

@@ -15,16 +15,24 @@ struct OrderDetailsView: View {
if hasAddressInfo { if hasAddressInfo {
addressCard addressCard
} }
reorderButton
helpFooter helpFooter
} }
.padding(.horizontal, 20) .padding(.horizontal, 20)
.padding(.top, 14) .padding(.top, 14)
.padding(.bottom, UIDevice.bottomNotch + 24) .padding(.bottom, UIDevice.bottomNotch + 110)
} }
.background(AppColors.backgroundLight) .background(AppColors.backgroundLight)
.navigationTitle("Detalhes do Pedido") .navigationTitle("Detalhes do Pedido")
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
.safeAreaInset(edge: .bottom) {
VStack {
reorderButton
.padding(.horizontal, 20)
.padding(.top, 10)
.padding(.bottom, max(10, UIDevice.bottomNotch))
}
.background(AppColors.backgroundLight.opacity(0.94))
}
} }
private var statusCard: some View { private var statusCard: some View {
@@ -66,7 +74,7 @@ struct OrderDetailsView: View {
Text(order.storeName?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false ? (order.storeName ?? "Loja") : "Loja") Text(order.storeName?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false ? (order.storeName ?? "Loja") : "Loja")
.font(AppTypography.heading2) .font(AppTypography.heading2)
.foregroundStyle(AppColors.textPrimary) .foregroundStyle(AppColors.textPrimary)
Text("Pedido #\(displayOrderTitle)") Text(storeSubtitle)
.font(AppTypography.body) .font(AppTypography.body)
.foregroundStyle(AppColors.textMuted) .foregroundStyle(AppColors.textMuted)
} }
@@ -130,11 +138,31 @@ struct OrderDetailsView: View {
.font(AppTypography.body) .font(AppTypography.body)
.foregroundStyle(AppColors.textMuted) .foregroundStyle(AppColors.textMuted)
Spacer() Spacer()
Text(formatCurrency(subtotal)) Text(formatCurrency(subtotalValue))
.font(AppTypography.body) .font(AppTypography.body)
.foregroundStyle(AppColors.textMuted) .foregroundStyle(AppColors.textMuted)
} }
HStack {
Text("Taxa de entrega")
.font(AppTypography.body)
.foregroundStyle(AppColors.textMuted)
Spacer()
Text(formatCurrency(deliveryFeeValue))
.font(AppTypography.body)
.foregroundStyle(AppColors.textMuted)
}
HStack {
Text("Desconto")
.font(AppTypography.body)
.foregroundStyle(AppColors.textMuted)
Spacer()
Text("- \(formatCurrency(discountValue))")
.font(AppTypography.body)
.foregroundStyle(Color(hex: "#18A957"))
}
Divider() Divider()
HStack { HStack {
@@ -142,7 +170,7 @@ struct OrderDetailsView: View {
.font(AppTypography.heading2) .font(AppTypography.heading2)
.foregroundStyle(AppColors.textPrimary) .foregroundStyle(AppColors.textPrimary)
Spacer() Spacer()
Text(formatCurrency(order.total ?? subtotal)) Text(formatCurrency(totalValue))
.font(AppTypography.heading1) .font(AppTypography.heading1)
.foregroundStyle(AppColors.textPrimary) .foregroundStyle(AppColors.textPrimary)
} }
@@ -217,6 +245,26 @@ struct OrderDetailsView: View {
} }
} }
private var subtotalValue: Double {
order.subtotal ?? subtotal
}
private var deliveryFeeValue: Double {
max(0, order.deliveryFee ?? 0)
}
private var discountValue: Double {
max(0, order.discount ?? 0)
}
private var totalValue: Double {
if let total = order.total {
return total
}
let calculated = subtotalValue + deliveryFeeValue - discountValue
return max(0, calculated)
}
private var hasAddressInfo: Bool { private var hasAddressInfo: Bool {
deliveryAddressLine.isEmpty == false || deliveryAddressLine2.isEmpty == false deliveryAddressLine.isEmpty == false || deliveryAddressLine2.isEmpty == false
} }
@@ -241,6 +289,13 @@ struct OrderDetailsView: View {
.joined(separator: "") .joined(separator: "")
} }
private var storeSubtitle: String {
if deliveryAddressLine2.isEmpty == false {
return deliveryAddressLine2
}
return "Pedido #\(displayOrderTitle)"
}
private var statusTitle: String { private var statusTitle: String {
let status = normalized(order.status) let status = normalized(order.status)
if status.contains("CANCEL") { return "Pedido cancelado" } if status.contains("CANCEL") { return "Pedido cancelado" }

View File

@@ -7,11 +7,12 @@ struct OrdersView: View {
@State var hasLoadedOnce = false @State var hasLoadedOnce = false
@State var storeRatingByStoreId: [String: Double] = [:] @State var storeRatingByStoreId: [String: Double] = [:]
@State var storeRatingByStoreName: [String: Double] = [:] @State var storeRatingByStoreName: [String: Double] = [:]
@State var selectedOrderRoute: OrderRouteContext? = nil
var body: some View { var body: some View {
ScrollView(showsIndicators: false) { ScrollView(showsIndicators: false) {
VStack(alignment: .leading, spacing: 14) { VStack(alignment: .leading, spacing: 14) {
Text("Histórico de Pedidos") Text("Meus Pedidos")
.font(AppTypography.heading1) .font(AppTypography.heading1)
.foregroundStyle(AppColors.textPrimary) .foregroundStyle(AppColors.textPrimary)
.padding(.top, 6) .padding(.top, 6)
@@ -34,21 +35,7 @@ struct OrdersView: View {
.padding(.top, 24) .padding(.top, 24)
} else { } else {
ForEach(orders) { order in ForEach(orders) { order in
let trackingId = trackingOrderId(for: order) orderCard(order)
NavigationLink {
OrderEntryDestinationView(
orderId: trackingId,
initialShortId: order.shortId,
fallbackPaymentMethod: order.paymentMethod,
fallbackTotal: order.total
)
} label: {
orderRow(order)
}
.buttonStyle(.plain)
.padding(14)
.background(AppColors.surface)
.clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous))
} }
} }
} }
@@ -66,103 +53,148 @@ struct OrdersView: View {
await loadOrders(force: true) await loadOrders(force: true)
await refreshStoreRatings() await refreshStoreRatings()
} }
.navigationDestination(item: $selectedOrderRoute) { context in
OrderEntryDestinationView(
orderId: context.orderId,
initialShortId: context.shortId,
fallbackPaymentMethod: context.paymentMethod,
fallbackTotal: context.total
)
}
} }
private func orderRow(_ order: AppOrderSummary) -> some View { private func orderCard(_ order: AppOrderSummary) -> some View {
HStack(spacing: 12) { let status = orderVisualStatus(for: order)
RoundedRectangle(cornerRadius: 10, style: .continuous) let route = OrderRouteContext(
.fill(AppColors.brandSoft) orderId: trackingOrderId(for: order),
.frame(width: 44, height: 44) shortId: order.shortId,
.overlay( paymentMethod: order.paymentMethod,
Image(systemName: "bag.fill") total: order.total
.foregroundStyle(AppColors.primary)
) )
VStack(alignment: .leading, spacing: 4) { return VStack(alignment: .leading, spacing: 14) {
HStack(spacing: 12) {
AsyncStoreImage(imageURL: resolvedMediaURL(order.storeLogoURL))
.frame(width: 88, height: 88)
.clipShape(RoundedRectangle(cornerRadius: 24, style: .continuous))
.background(AppColors.brandSoft, in: RoundedRectangle(cornerRadius: 24, style: .continuous))
VStack(alignment: .leading, spacing: 5) {
Text(order.storeName?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false ? (order.storeName ?? "Pedido") : "Pedido #\(order.shortId ?? order.id)") Text(order.storeName?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false ? (order.storeName ?? "Pedido") : "Pedido #\(order.shortId ?? order.id)")
.font(AppTypography.heading3) .font(AppTypography.heading2)
.foregroundStyle(AppColors.textPrimary) .foregroundStyle(AppColors.textPrimary)
.lineLimit(1)
HStack(spacing: 6) { HStack(spacing: 6) {
Text(humanReadableStatus(for: order)) Text(orderMetaText(order))
.font(AppTypography.caption) .font(AppTypography.body)
.foregroundStyle(AppColors.textMuted) .foregroundStyle(AppColors.textMuted)
.lineLimit(1)
if let rating = storeRating(for: order) { if let rating = storeRating(for: order) {
Text("") Text("")
.font(AppTypography.caption) .font(AppTypography.body)
.foregroundStyle(AppColors.textMuted) .foregroundStyle(AppColors.textMuted)
HStack(spacing: 4) {
Image(systemName: "star.fill") Image(systemName: "star.fill")
.font(.system(size: 11, weight: .bold)) .font(.system(size: 11, weight: .bold))
.foregroundStyle(Color(hex: "#7CF02A")) .foregroundStyle(Color(hex: "#7CF02A"))
Text(String(format: "%.1f", rating).replacingOccurrences(of: ".", with: ",")) Text(String(format: "%.1f", rating).replacingOccurrences(of: ".", with: ","))
.font(AppTypography.caption) .font(AppTypography.body)
.foregroundStyle(AppColors.textMuted) .foregroundStyle(AppColors.textMuted)
} }
} }
} }
}
Spacer() Spacer(minLength: 0)
VStack(alignment: .trailing, spacing: 4) { Text(status.badgeTitle)
Text(formatCurrency(order.total ?? 0))
.font(AppTypography.heading3)
.foregroundStyle(AppColors.textPrimary)
if let createdAt = formatDate(order.createdAt) {
Text(createdAt)
.font(AppTypography.caption) .font(AppTypography.caption)
.foregroundStyle(AppColors.textMuted) .foregroundStyle(status.badgeForeground)
} .padding(.horizontal, 10)
} .padding(.vertical, 6)
} .background(status.badgeBackground)
.clipShape(Capsule())
} }
private func humanReadableStatus(for order: AppOrderSummary) -> String { Divider()
HStack(spacing: 12) {
Button(status.isCanceled ? "Ajuda" : "Ver Detalhes") {
selectedOrderRoute = route
}
.font(AppTypography.heading2)
.foregroundStyle(AppColors.textMuted)
.buttonStyle(.plain)
Spacer(minLength: 12)
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.heading2)
}
.foregroundStyle(status.actionForeground)
.padding(.horizontal, 24)
.padding(.vertical, 12)
.background(status.actionBackground)
.clipShape(Capsule())
}
.buttonStyle(.plain)
}
}
.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 explicitLabel = (order.statusLabel ?? "").trimmingCharacters(in: .whitespacesAndNewlines) let explicitLabel = (order.statusLabel ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
if explicitLabel.isEmpty == false { if explicitLabel.isEmpty == false {
return explicitLabel let normalizedLabel = explicitLabel.uppercased()
if normalizedLabel.contains("CANCEL") { return .canceled }
if normalizedLabel.contains("ENTREG") || normalizedLabel.contains("COMPLET") { return .delivered }
} }
let detailed = (order.statusDetailed ?? "").uppercased() let normalized = ((order.statusDetailed ?? order.status) ?? "").uppercased()
switch detailed { if normalized.contains("CANCEL") {
case "PENDING_PAYMENT": return .canceled
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
} }
if normalized.contains("COMPLETED") || normalized.contains("DELIVERED") {
let raw = order.status ?? order.paymentStatus ?? "Pendente" return .delivered
let normalized = raw.uppercased() }
if normalized.contains("PAYMENT_PENDING") { return "Aguardando pagamento" } return .inProgress
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
} }
private func formatCurrency(_ value: Double) -> String { private func formatCurrency(_ value: Double) -> String {
String(format: "R$ %.2f", value).replacingOccurrences(of: ".", with: ",") String(format: "R$ %.2f", value).replacingOccurrences(of: ".", with: ",")
} }
private func formatDate(_ isoValue: String?) -> String? { 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 } guard let isoValue, isoValue.isEmpty == false else { return nil }
let iso = ISO8601DateFormatter() let iso = ISO8601DateFormatter()
iso.formatOptions = [.withInternetDateTime, .withFractionalSeconds] iso.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
@@ -175,7 +207,7 @@ struct OrdersView: View {
let formatter = DateFormatter() let formatter = DateFormatter()
formatter.locale = Locale(identifier: "pt_BR") formatter.locale = Locale(identifier: "pt_BR")
formatter.dateFormat = "dd/MM HH:mm" formatter.dateFormat = "dd MMM, HH:mm"
return formatter.string(from: date) return formatter.string(from: date)
} }
@@ -223,7 +255,6 @@ struct OrdersView: View {
trackedMapped = cachedTracked.map { trackedMapped = cachedTracked.map {
AppOrderSummary.fromTracked($0) AppOrderSummary.fromTracked($0)
} }
// Use tracked-only list as bootstrap data only on first load.
if hasLoadedOnce == false, previousOrders.isEmpty { if hasLoadedOnce == false, previousOrders.isEmpty {
orders = mergeOrders(apiOrders: [], trackedOrders: trackedMapped) orders = mergeOrders(apiOrders: [], trackedOrders: trackedMapped)
} }
@@ -269,7 +300,6 @@ struct OrdersView: View {
} }
} }
// API order is authoritative for fallback ordering (usually newest first).
for (index, item) in apiOrders.enumerated() { for (index, item) in apiOrders.enumerated() {
let key = identityKey(for: item) let key = identityKey(for: item)
map[key] = item map[key] = item
@@ -404,8 +434,66 @@ struct OrdersView: View {
} }
return nil return nil
} }
private func resolvedMediaURL(_ raw: String?) -> String? {
ImageSourceResolver.resolve(raw)
}
} }
private 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 { extension AppOrderSummary {
static func fromTracked(_ tracked: PublicOrderResult) -> AppOrderSummary { static func fromTracked(_ tracked: PublicOrderResult) -> AppOrderSummary {
AppOrderSummary( AppOrderSummary(