feat(orders): separar reviews e criar detalhe de pedido

This commit is contained in:
Daniel Arantes Loverde
2026-03-05 10:10:03 -03:00
parent 56d6ff7223
commit a8e3631177
3 changed files with 586 additions and 0 deletions

View File

@@ -0,0 +1,321 @@
import SwiftUI
struct OrderDetailsView: View {
let order: PublicOrderResult
let orderId: String
let initialShortId: String?
var body: some View {
ScrollView(showsIndicators: false) {
VStack(spacing: 14) {
statusCard
storeCard
itemsCard
totalsCard
if hasAddressInfo {
addressCard
}
reorderButton
helpFooter
}
.padding(.horizontal, 20)
.padding(.top, 14)
.padding(.bottom, UIDevice.bottomNotch + 24)
}
.background(AppColors.backgroundLight)
.navigationTitle("Detalhes do Pedido")
.navigationBarTitleDisplayMode(.inline)
}
private var statusCard: some View {
HStack(spacing: 14) {
Circle()
.fill(AppColors.brandSoft)
.frame(width: 54, height: 54)
.overlay(
Image(systemName: statusIcon)
.font(.system(size: 22, weight: .bold))
.foregroundStyle(statusColor)
)
VStack(alignment: .leading, spacing: 2) {
Text(statusTitle)
.font(AppTypography.heading3)
.foregroundStyle(AppColors.textPrimary)
Text(statusDateText)
.font(AppTypography.body)
.foregroundStyle(AppColors.textMuted)
}
Spacer(minLength: 0)
}
.padding(16)
.background(AppColors.surface)
.clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous))
}
private var storeCard: some View {
HStack(spacing: 12) {
AsyncStoreImage(imageURL: resolvedMediaURL(order.storeLogoURL))
.frame(width: 54, height: 54)
.clipShape(Circle())
.background(AppColors.brandSoft, in: Circle())
VStack(alignment: .leading, spacing: 4) {
Text(order.storeName?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false ? (order.storeName ?? "Loja") : "Loja")
.font(AppTypography.heading2)
.foregroundStyle(AppColors.textPrimary)
Text("Pedido #\(displayOrderTitle)")
.font(AppTypography.body)
.foregroundStyle(AppColors.textMuted)
}
Spacer(minLength: 0)
Text("Ver loja")
.font(AppTypography.body)
.foregroundStyle(Color(hex: "#A5D645"))
}
.padding(16)
.background(AppColors.surface)
.clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous))
}
private var itemsCard: some View {
VStack(alignment: .leading, spacing: 14) {
Text("Itens do Pedido")
.font(AppTypography.heading2)
.foregroundStyle(AppColors.textPrimary)
ForEach(order.items) { item in
HStack(alignment: .top, spacing: 12) {
Text("\(max(1, item.qty ?? 1))")
.font(AppTypography.heading3)
.foregroundStyle(AppColors.textPrimary)
.frame(width: 30, height: 30)
.background(AppColors.backgroundLight)
.clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
VStack(alignment: .leading, spacing: 2) {
Text(item.name?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false ? (item.name ?? "Item") : "Item")
.font(AppTypography.heading3)
.foregroundStyle(AppColors.textPrimary)
}
Spacer(minLength: 0)
if let price = item.price {
Text(formatCurrency(price))
.font(AppTypography.heading3)
.foregroundStyle(AppColors.textPrimary)
}
}
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(16)
.background(AppColors.surface)
.clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous))
}
private var totalsCard: some View {
VStack(alignment: .leading, spacing: 10) {
Text("Resumo de Valores")
.font(AppTypography.heading2)
.foregroundStyle(AppColors.textPrimary)
HStack {
Text("Subtotal")
.font(AppTypography.body)
.foregroundStyle(AppColors.textMuted)
Spacer()
Text(formatCurrency(subtotal))
.font(AppTypography.body)
.foregroundStyle(AppColors.textMuted)
}
Divider()
HStack {
Text("Total")
.font(AppTypography.heading2)
.foregroundStyle(AppColors.textPrimary)
Spacer()
Text(formatCurrency(order.total ?? subtotal))
.font(AppTypography.heading1)
.foregroundStyle(AppColors.textPrimary)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(16)
.background(AppColors.surface)
.clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous))
}
private var addressCard: some View {
VStack(alignment: .leading, spacing: 12) {
HStack(spacing: 10) {
Circle()
.fill(AppColors.backgroundLight)
.frame(width: 34, height: 34)
.overlay(
Image(systemName: "mappin.circle.fill")
.font(.system(size: 18, weight: .semibold))
.foregroundStyle(AppColors.textMuted)
)
Text("ENDEREÇO DE ENTREGA")
.font(AppTypography.caption)
.foregroundStyle(AppColors.textMuted)
Spacer(minLength: 0)
}
Text(deliveryAddressLine)
.font(AppTypography.body)
.foregroundStyle(AppColors.textPrimary)
if deliveryAddressLine2.isEmpty == false {
Text(deliveryAddressLine2)
.font(AppTypography.body)
.foregroundStyle(AppColors.textMuted)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(16)
.background(AppColors.surface)
.clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous))
}
private var reorderButton: some View {
Button("Pedir Novamente") {
SnackbarCenter.shared.show(
title: "Recompra será integrada com o catálogo em breve.",
style: .info,
icon: "cart.badge.plus",
duration: 2.0
)
}
.font(AppTypography.heading2)
.foregroundStyle(Color(hex: "#0E1A06"))
.frame(maxWidth: .infinity, minHeight: 56)
.background(Color(hex: "#C8F06E"))
.clipShape(Capsule())
.buttonStyle(.plain)
}
private var helpFooter: some View {
Text("Precisa de ajuda com esse pedido?")
.font(AppTypography.body)
.foregroundStyle(Color(hex: "#A5D645"))
.frame(maxWidth: .infinity, alignment: .center)
.padding(.vertical, 8)
}
private var subtotal: Double {
order.items.reduce(0) { partial, item in
partial + (Double(max(1, item.qty ?? 1)) * (item.price ?? 0))
}
}
private var hasAddressInfo: Bool {
deliveryAddressLine.isEmpty == false || deliveryAddressLine2.isEmpty == false
}
private var deliveryAddressLine: String {
guard let address = order.deliveryAddress else { return "" }
let street = normalizedText(address.street)
let number = normalizedText(address.number)
let base = [street, number].filter { $0.isEmpty == false }.joined(separator: ", ")
if base.isEmpty == false { return base }
return normalizedText(address.label)
}
private var deliveryAddressLine2: String {
guard let address = order.deliveryAddress else { return "" }
let neighborhood = normalizedText(address.neighborhood)
let city = normalizedText(address.city)
let state = normalizedText(address.state)
let zip = normalizedText(address.zip)
return [neighborhood, city, state, zip]
.filter { $0.isEmpty == false }
.joined(separator: "")
}
private var statusTitle: String {
let status = normalized(order.status)
if status.contains("CANCEL") { return "Pedido cancelado" }
if status.contains("COMPLETED") || status.contains("DELIVERED") {
return normalized(order.deliveryType ?? order.deliveryTypeLabel).contains("PICKUP") ? "Pedido retirado" : "Pedido concluído"
}
if status.contains("DELIVER") || status.contains("ROTA") { return "Pedido em rota" }
if status.contains("READY") { return "Pedido pronto" }
if status.contains("PREPAR") { return "Pedido em produção" }
return "Pedido confirmado"
}
private var statusDateText: String {
if let formatted = formatDate(order.updatedAt ?? order.createdAt) {
return "\(statusDatePrefix) \(formatted)"
}
return statusDatePrefix
}
private var statusDatePrefix: String {
if statusTitle.contains("cancelado") { return "Cancelado em" }
if statusTitle.contains("retirado") { return "Retirado em" }
if statusTitle.contains("concluído") { return "Entregue em" }
return "Atualizado em"
}
private var statusIcon: String {
statusTitle.contains("cancelado") ? "xmark" : "checkmark"
}
private var statusColor: Color {
statusTitle.contains("cancelado") ? Color.red : AppColors.primary
}
private var displayOrderTitle: String {
if let short = order.shortId, short.isEmpty == false { return short }
let orderIdValue = order.id.trimmingCharacters(in: .whitespacesAndNewlines)
if orderIdValue.isEmpty == false { return orderIdValue }
if let initialShortId, initialShortId.isEmpty == false { return initialShortId }
return String(orderId.prefix(6))
}
private func formatCurrency(_ value: Double) -> String {
String(format: "R$ %.2f", value).replacingOccurrences(of: ".", with: ",")
}
private func formatDate(_ isoValue: String?) -> String? {
guard let isoValue, isoValue.isEmpty == false else { return nil }
let iso = ISO8601DateFormatter()
iso.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
var date = iso.date(from: isoValue)
if date == nil {
iso.formatOptions = [.withInternetDateTime]
date = iso.date(from: isoValue)
}
guard let date else { return nil }
let formatter = DateFormatter()
formatter.locale = Locale(identifier: "pt_BR")
formatter.dateFormat = "dd MMM, HH:mm"
return formatter.string(from: date)
}
private func normalized(_ value: String?) -> String {
(value ?? "")
.folding(options: [.diacriticInsensitive, .caseInsensitive], locale: .current)
.uppercased()
.trimmingCharacters(in: .whitespacesAndNewlines)
}
private func resolvedMediaURL(_ raw: String?) -> String? {
ImageSourceResolver.resolve(raw)
}
private func normalizedText(_ value: String?) -> String {
(value ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
}
}

View File

@@ -479,6 +479,7 @@ struct OrderEntryDestinationView: View {
@State var pixContext: PixPaymentContext? = nil
@State var cardContext: CardPaymentContext? = nil
@State var orderTrackingContext: OrderTrackingContext? = nil
@State var orderDetails: PublicOrderResult? = nil
var body: some View {
Group {
@@ -511,6 +512,8 @@ struct OrderEntryDestinationView: View {
orderTrackingContext = OrderTrackingContext(orderId: cardContext.orderId, shortId: cardContext.shortId)
}
)
} else if let orderDetails {
OrderDetailsView(order: orderDetails, orderId: orderId, initialShortId: initialShortId)
} else {
OrderTrackingView(orderId: orderId, initialShortId: initialShortId)
}
@@ -531,6 +534,10 @@ struct OrderEntryDestinationView: View {
let order = await fetchOrderForRouting()
guard let order else { return }
if shouldOpenOrderDetails(for: order) {
orderDetails = order
return
}
guard shouldOpenPaymentScreen(for: order) else { return }
let normalizedMethod = normalizePaymentMethod(order)
@@ -592,6 +599,13 @@ struct OrderEntryDestinationView: View {
return method == "PIX" || method == "CREDIT_CARD" || method == "DEBIT_CARD"
}
func shouldOpenOrderDetails(for order: PublicOrderResult) -> Bool {
let status = normalize(order.status)
if status.contains("CANCEL") { return true }
if status.contains("COMPLETED") || status.contains("DELIVERED") { return true }
return false
}
func shouldOpenPaymentScreen(for order: PublicOrderResult) -> Bool {
guard order.isPaymentConfirmed == false else { return false }
guard isOnlinePaymentMethod(order) else { return false }