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

@@ -7,11 +7,12 @@ struct OrdersView: View {
@State var hasLoadedOnce = false
@State var storeRatingByStoreId: [String: Double] = [:]
@State var storeRatingByStoreName: [String: Double] = [:]
@State var selectedOrderRoute: OrderRouteContext? = nil
var body: some View {
ScrollView(showsIndicators: false) {
VStack(alignment: .leading, spacing: 14) {
Text("Histórico de Pedidos")
Text("Meus Pedidos")
.font(AppTypography.heading1)
.foregroundStyle(AppColors.textPrimary)
.padding(.top, 6)
@@ -34,21 +35,7 @@ struct OrdersView: View {
.padding(.top, 24)
} else {
ForEach(orders) { order in
let trackingId = trackingOrderId(for: 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))
orderCard(order)
}
}
}
@@ -66,103 +53,148 @@ struct OrdersView: View {
await loadOrders(force: true)
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 {
HStack(spacing: 12) {
RoundedRectangle(cornerRadius: 10, style: .continuous)
.fill(AppColors.brandSoft)
.frame(width: 44, height: 44)
.overlay(
Image(systemName: "bag.fill")
.foregroundStyle(AppColors.primary)
)
private func orderCard(_ order: AppOrderSummary) -> some View {
let status = orderVisualStatus(for: order)
let route = OrderRouteContext(
orderId: trackingOrderId(for: order),
shortId: order.shortId,
paymentMethod: order.paymentMethod,
total: order.total
)
VStack(alignment: .leading, spacing: 4) {
Text(order.storeName?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false ? (order.storeName ?? "Pedido") : "Pedido #\(order.shortId ?? order.id)")
.font(AppTypography.heading3)
.foregroundStyle(AppColors.textPrimary)
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))
HStack(spacing: 6) {
Text(humanReadableStatus(for: order))
.font(AppTypography.caption)
.foregroundStyle(AppColors.textMuted)
if let rating = storeRating(for: order) {
Text("")
.font(AppTypography.caption)
VStack(alignment: .leading, spacing: 5) {
Text(order.storeName?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false ? (order.storeName ?? "Pedido") : "Pedido #\(order.shortId ?? order.id)")
.font(AppTypography.heading2)
.foregroundStyle(AppColors.textPrimary)
.lineLimit(1)
HStack(spacing: 6) {
Text(orderMetaText(order))
.font(AppTypography.body)
.foregroundStyle(AppColors.textMuted)
HStack(spacing: 4) {
.lineLimit(1)
if let rating = storeRating(for: order) {
Text("")
.font(AppTypography.body)
.foregroundStyle(AppColors.textMuted)
Image(systemName: "star.fill")
.font(.system(size: 11, weight: .bold))
.foregroundStyle(Color(hex: "#7CF02A"))
Text(String(format: "%.1f", rating).replacingOccurrences(of: ".", with: ","))
.font(AppTypography.caption)
.font(AppTypography.body)
.foregroundStyle(AppColors.textMuted)
}
}
}
Spacer(minLength: 0)
Text(status.badgeTitle)
.font(AppTypography.caption)
.foregroundStyle(status.badgeForeground)
.padding(.horizontal, 10)
.padding(.vertical, 6)
.background(status.badgeBackground)
.clipShape(Capsule())
}
Spacer()
Divider()
VStack(alignment: .trailing, spacing: 4) {
Text(formatCurrency(order.total ?? 0))
.font(AppTypography.heading3)
.foregroundStyle(AppColors.textPrimary)
if let createdAt = formatDate(order.createdAt) {
Text(createdAt)
.font(AppTypography.caption)
.foregroundStyle(AppColors.textMuted)
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 humanReadableStatus(for order: AppOrderSummary) -> String {
private func orderVisualStatus(for order: AppOrderSummary) -> OrderRowStatusStyle {
let explicitLabel = (order.statusLabel ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
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()
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 normalized = ((order.statusDetailed ?? order.status) ?? "").uppercased()
if normalized.contains("CANCEL") {
return .canceled
}
let raw = order.status ?? order.paymentStatus ?? "Pendente"
let normalized = raw.uppercased()
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
if normalized.contains("COMPLETED") || normalized.contains("DELIVERED") {
return .delivered
}
return .inProgress
}
private func formatCurrency(_ value: Double) -> String {
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 }
let iso = ISO8601DateFormatter()
iso.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
@@ -175,7 +207,7 @@ struct OrdersView: View {
let formatter = DateFormatter()
formatter.locale = Locale(identifier: "pt_BR")
formatter.dateFormat = "dd/MM HH:mm"
formatter.dateFormat = "dd MMM, HH:mm"
return formatter.string(from: date)
}
@@ -223,7 +255,6 @@ struct OrdersView: View {
trackedMapped = cachedTracked.map {
AppOrderSummary.fromTracked($0)
}
// Use tracked-only list as bootstrap data only on first load.
if hasLoadedOnce == false, previousOrders.isEmpty {
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() {
let key = identityKey(for: item)
map[key] = item
@@ -404,8 +434,66 @@ struct OrdersView: View {
}
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 {
static func fromTracked(_ tracked: PublicOrderResult) -> AppOrderSummary {
AppOrderSummary(