diff --git a/pedi-foods/Sources/PediFoods/Services/ApiOrderTrackingModels.swift b/pedi-foods/Sources/PediFoods/Services/ApiOrderTrackingModels.swift index ce04e0f..f72d51d 100644 --- a/pedi-foods/Sources/PediFoods/Services/ApiOrderTrackingModels.swift +++ b/pedi-foods/Sources/PediFoods/Services/ApiOrderTrackingModels.swift @@ -81,6 +81,9 @@ struct PublicOrderResult: Codable, Identifiable { let nextAction: String? let deliveryType: String? let deliveryTypeLabel: String? + let subtotal: Double? + let deliveryFee: Double? + let discount: Double? let total: Double? let storeName: String? let storeLogoURL: String? @@ -111,6 +114,15 @@ struct PublicOrderResult: Codable, Identifiable { case nextAction case deliveryType case deliveryTypeLabel + case subtotal + case subTotal + case itemsTotal + case deliveryFee + case delivery_fee + case fee + case discount + case desconto + case couponDiscount case total case storeName case storeLogo @@ -148,6 +160,9 @@ struct PublicOrderResult: Codable, Identifiable { nextAction: String? = nil, deliveryType: String? = nil, deliveryTypeLabel: String? = nil, + subtotal: Double? = nil, + deliveryFee: Double? = nil, + discount: Double? = nil, total: Double? = nil, storeName: String? = nil, storeLogoURL: String? = nil, @@ -176,6 +191,9 @@ struct PublicOrderResult: Codable, Identifiable { self.nextAction = nextAction self.deliveryType = deliveryType self.deliveryTypeLabel = deliveryTypeLabel + self.subtotal = subtotal + self.deliveryFee = deliveryFee + self.discount = discount self.total = total self.storeName = storeName self.storeLogoURL = storeLogoURL @@ -217,6 +235,9 @@ struct PublicOrderResult: Codable, Identifiable { } deliveryType = ApiService.decodeFlexibleString(from: container, keys: [.deliveryType]) 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]) storeName = ApiService.decodeFlexibleString(from: container, keys: [.storeName]) 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(deliveryType, forKey: .deliveryType) 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(storeName, forKey: .storeName) try container.encodeIfPresent(createdAt, forKey: .createdAt) diff --git a/pedi-foods/Sources/PediFoods/Views/Main/OrderDetailsView.swift b/pedi-foods/Sources/PediFoods/Views/Main/OrderDetailsView.swift index 4568e9b..8b5c33a 100644 --- a/pedi-foods/Sources/PediFoods/Views/Main/OrderDetailsView.swift +++ b/pedi-foods/Sources/PediFoods/Views/Main/OrderDetailsView.swift @@ -15,16 +15,24 @@ struct OrderDetailsView: View { if hasAddressInfo { addressCard } - reorderButton helpFooter } .padding(.horizontal, 20) .padding(.top, 14) - .padding(.bottom, UIDevice.bottomNotch + 24) + .padding(.bottom, UIDevice.bottomNotch + 110) } .background(AppColors.backgroundLight) .navigationTitle("Detalhes do Pedido") .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 { @@ -66,7 +74,7 @@ struct OrderDetailsView: View { Text(order.storeName?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false ? (order.storeName ?? "Loja") : "Loja") .font(AppTypography.heading2) .foregroundStyle(AppColors.textPrimary) - Text("Pedido #\(displayOrderTitle)") + Text(storeSubtitle) .font(AppTypography.body) .foregroundStyle(AppColors.textMuted) } @@ -130,11 +138,31 @@ struct OrderDetailsView: View { .font(AppTypography.body) .foregroundStyle(AppColors.textMuted) Spacer() - Text(formatCurrency(subtotal)) + Text(formatCurrency(subtotalValue)) .font(AppTypography.body) .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() HStack { @@ -142,15 +170,15 @@ struct OrderDetailsView: View { .font(AppTypography.heading2) .foregroundStyle(AppColors.textPrimary) Spacer() - Text(formatCurrency(order.total ?? subtotal)) + Text(formatCurrency(totalValue)) .font(AppTypography.heading1) .foregroundStyle(AppColors.textPrimary) } } .frame(maxWidth: .infinity, alignment: .leading) - .padding(16) - .background(AppColors.surface) - .clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous)) + .padding(16) + .background(AppColors.surface) + .clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous)) } private var addressCard: some View { @@ -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 { deliveryAddressLine.isEmpty == false || deliveryAddressLine2.isEmpty == false } @@ -241,6 +289,13 @@ struct OrderDetailsView: View { .joined(separator: " • ") } + private var storeSubtitle: String { + if deliveryAddressLine2.isEmpty == false { + return deliveryAddressLine2 + } + return "Pedido #\(displayOrderTitle)" + } + private var statusTitle: String { let status = normalized(order.status) if status.contains("CANCEL") { return "Pedido cancelado" } diff --git a/pedi-foods/Sources/PediFoods/Views/Main/OrdersView.swift b/pedi-foods/Sources/PediFoods/Views/Main/OrdersView.swift index 86b666f..b606bbc 100644 --- a/pedi-foods/Sources/PediFoods/Views/Main/OrdersView.swift +++ b/pedi-foods/Sources/PediFoods/Views/Main/OrdersView.swift @@ -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(