diff --git a/pedi-foods/Darwin/Assets.xcassets/tracking-canceled.imageset/Contents.json b/pedi-foods/Darwin/Assets.xcassets/tracking-canceled.imageset/Contents.json new file mode 100644 index 0000000..bd8508b --- /dev/null +++ b/pedi-foods/Darwin/Assets.xcassets/tracking-canceled.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "tracking-canceled.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/pedi-foods/Darwin/Assets.xcassets/tracking-canceled.imageset/tracking-canceled.png b/pedi-foods/Darwin/Assets.xcassets/tracking-canceled.imageset/tracking-canceled.png new file mode 100644 index 0000000..6b0acbc Binary files /dev/null and b/pedi-foods/Darwin/Assets.xcassets/tracking-canceled.imageset/tracking-canceled.png differ diff --git a/pedi-foods/Darwin/Assets.xcassets/tracking-completed.imageset/Contents.json b/pedi-foods/Darwin/Assets.xcassets/tracking-completed.imageset/Contents.json new file mode 100644 index 0000000..210ff36 --- /dev/null +++ b/pedi-foods/Darwin/Assets.xcassets/tracking-completed.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "tracking-completed.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/pedi-foods/Darwin/Assets.xcassets/tracking-completed.imageset/tracking-completed.png b/pedi-foods/Darwin/Assets.xcassets/tracking-completed.imageset/tracking-completed.png new file mode 100644 index 0000000..fee3d7f Binary files /dev/null and b/pedi-foods/Darwin/Assets.xcassets/tracking-completed.imageset/tracking-completed.png differ diff --git a/pedi-foods/Darwin/Assets.xcassets/tracking-delivering.imageset/Contents.json b/pedi-foods/Darwin/Assets.xcassets/tracking-delivering.imageset/Contents.json new file mode 100644 index 0000000..427443e --- /dev/null +++ b/pedi-foods/Darwin/Assets.xcassets/tracking-delivering.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "tracking-delivering.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/pedi-foods/Darwin/Assets.xcassets/tracking-delivering.imageset/tracking-delivering.png b/pedi-foods/Darwin/Assets.xcassets/tracking-delivering.imageset/tracking-delivering.png new file mode 100644 index 0000000..2d996ae Binary files /dev/null and b/pedi-foods/Darwin/Assets.xcassets/tracking-delivering.imageset/tracking-delivering.png differ diff --git a/pedi-foods/Darwin/Assets.xcassets/tracking-pending.imageset/Contents.json b/pedi-foods/Darwin/Assets.xcassets/tracking-pending.imageset/Contents.json new file mode 100644 index 0000000..eb65c04 --- /dev/null +++ b/pedi-foods/Darwin/Assets.xcassets/tracking-pending.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "tracking-pending.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/pedi-foods/Darwin/Assets.xcassets/tracking-pending.imageset/tracking-pending.png b/pedi-foods/Darwin/Assets.xcassets/tracking-pending.imageset/tracking-pending.png new file mode 100644 index 0000000..a4a9344 Binary files /dev/null and b/pedi-foods/Darwin/Assets.xcassets/tracking-pending.imageset/tracking-pending.png differ diff --git a/pedi-foods/Darwin/Assets.xcassets/tracking-preparing.imageset/Contents.json b/pedi-foods/Darwin/Assets.xcassets/tracking-preparing.imageset/Contents.json new file mode 100644 index 0000000..e0a94c8 --- /dev/null +++ b/pedi-foods/Darwin/Assets.xcassets/tracking-preparing.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "tracking-preparing.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/pedi-foods/Darwin/Assets.xcassets/tracking-preparing.imageset/tracking-preparing.png b/pedi-foods/Darwin/Assets.xcassets/tracking-preparing.imageset/tracking-preparing.png new file mode 100644 index 0000000..23fc9a7 Binary files /dev/null and b/pedi-foods/Darwin/Assets.xcassets/tracking-preparing.imageset/tracking-preparing.png differ diff --git a/pedi-foods/Darwin/Assets.xcassets/tracking-ready.imageset/Contents.json b/pedi-foods/Darwin/Assets.xcassets/tracking-ready.imageset/Contents.json new file mode 100644 index 0000000..f5e3c56 --- /dev/null +++ b/pedi-foods/Darwin/Assets.xcassets/tracking-ready.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "tracking-ready.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/pedi-foods/Darwin/Assets.xcassets/tracking-ready.imageset/tracking-ready.png b/pedi-foods/Darwin/Assets.xcassets/tracking-ready.imageset/tracking-ready.png new file mode 100644 index 0000000..fe2f0f2 Binary files /dev/null and b/pedi-foods/Darwin/Assets.xcassets/tracking-ready.imageset/tracking-ready.png differ diff --git a/pedi-foods/Sources/PediFoods/Components/CachedRemoteImage.swift b/pedi-foods/Sources/PediFoods/Components/CachedRemoteImage.swift index 9dd9159..25beef3 100644 --- a/pedi-foods/Sources/PediFoods/Components/CachedRemoteImage.swift +++ b/pedi-foods/Sources/PediFoods/Components/CachedRemoteImage.swift @@ -15,7 +15,7 @@ struct CachedRemoteImage: View { init( imageURL: String?, - ttl: TimeInterval = 6 * 60 * 60, + ttl: TimeInterval = AppCacheTTL.twoHours, @ViewBuilder placeholder: () -> Placeholder ) { self.imageURL = imageURL @@ -100,7 +100,18 @@ final class CachedRemoteImageLoader: ObservableObject { guard let normalized, normalized.isEmpty == false else { return } #if canImport(UIKit) || canImport(AppKit) + let dataCacheKey = Self.dataURLCacheKey(normalized) + if let cachedDataImage: PlatformImage = AppContentCache.shared.value(for: dataCacheKey, as: PlatformImage.self) { +#if canImport(UIKit) + uiImage = cachedDataImage +#elseif canImport(AppKit) + nsImage = cachedDataImage +#endif + return + } + if let image = Self.imageFromDataURL(normalized) { + AppContentCache.shared.set(image, for: dataCacheKey, ttl: ttl) #if canImport(UIKit) uiImage = image #elseif canImport(AppKit) @@ -134,6 +145,12 @@ final class CachedRemoteImageLoader: ObservableObject { return normalized } + private static func dataURLCacheKey(_ source: String) -> String { + let head = String(source.prefix(48)) + let tail = String(source.suffix(48)) + return "data-image:\(source.count):\(head):\(tail)" + } + #if canImport(UIKit) || canImport(AppKit) private static func imageFromDataURL(_ source: String) -> PlatformImage? { let lower = source.lowercased() diff --git a/pedi-foods/Sources/PediFoods/ContentView.swift b/pedi-foods/Sources/PediFoods/ContentView.swift index e808642..640301e 100644 --- a/pedi-foods/Sources/PediFoods/ContentView.swift +++ b/pedi-foods/Sources/PediFoods/ContentView.swift @@ -52,6 +52,9 @@ struct ContentView: View { .onChange(of: appState.address.longitude) { _, _ in dismissAddressPickerIfAddressExists() } + .onReceive(NotificationCenter.default.publisher(for: .cartDidReset)) { _ in + appState.cart = CartState() + } .task(id: root) { await bootstrapSessionStateIfNeeded() } @@ -169,6 +172,7 @@ struct ContentView: View { tokenStore.clear() SessionStateStore.clearActiveUser() SessionStateStore.clearTrackedOrders() + SessionStateStore.clearPendingCartOrder() AppContentCache.shared.invalidate() AppImageCache.shared.invalidateAll() isBootstrappingSession = false diff --git a/pedi-foods/Sources/PediFoods/Resources/Localizable.xcstrings b/pedi-foods/Sources/PediFoods/Resources/Localizable.xcstrings index 35193af..eaa759a 100644 --- a/pedi-foods/Sources/PediFoods/Resources/Localizable.xcstrings +++ b/pedi-foods/Sources/PediFoods/Resources/Localizable.xcstrings @@ -78,10 +78,7 @@ } } }, - "Acompanhamento" : { - - }, - "Acompanhe seu pedido em tempo real" : { + "Acompanhamento em tempo real" : { }, "Add" : { @@ -179,6 +176,9 @@ } } } + }, + "Atualizando status do pedido..." : { + }, "Atualize os dados do endereço abaixo." : { "comment" : "A description below the fields in the \"Editar endereço\" form, instructing the user to update their address details.", @@ -233,6 +233,9 @@ "Cardápio indisponível no momento." : { "comment" : "A message displayed when a store's menu is unavailable.", "isCommentAutoGenerated" : true + }, + "Carregando pedido..." : { + }, "Carregando sua sessão..." : { "comment" : "A loading message displayed while bootstrapping the user's session.", @@ -250,6 +253,10 @@ "comment" : "The title of the field that displays the PIX code.", "isCommentAutoGenerated" : true }, + "Colar código" : { + "comment" : "A button that allows the user to paste their OTP code directly into the field.", + "isCommentAutoGenerated" : true + }, "Concluir" : { "comment" : "The text for a button that confirms and closes a sheet.", "isCommentAutoGenerated" : true @@ -262,6 +269,10 @@ "comment" : "A button label that translates to \"Confirm and Pay\".", "isCommentAutoGenerated" : true }, + "CONTATO" : { + "comment" : "The text on a button that takes the user to contact support.", + "isCommentAutoGenerated" : true + }, "Conteúdo da política de privacidade..." : { "comment" : "A placeholder text describing the content of the privacy policy.", "isCommentAutoGenerated" : true @@ -397,10 +408,6 @@ "comment" : "A button label that translates to \"Delete\".", "isCommentAutoGenerated" : true }, - "Expira em: %@" : { - "comment" : "A label displaying the expiration date of a payment code. The text inside the parentheses should be replaced with the actual expiration date.", - "isCommentAutoGenerated" : true - }, "Favorite" : { "comment" : "Item editor title label for marking the item as a favorite", "extractionState" : "stale", @@ -525,10 +532,6 @@ "comment" : "A button label that translates to \"Go to Payment\" in English.", "isCommentAutoGenerated" : true }, - "Já realizei o pagamento" : { - "comment" : "A button that lets the user know they've completed the payment.", - "isCommentAutoGenerated" : true - }, "Já tem uma conta?" : { "comment" : "A question displayed below the \"Cadastrar e Receber Código\" button in the registration view. The text is a hyperlink to the login view.", "isCommentAutoGenerated" : true @@ -592,6 +595,9 @@ "Monte sua pizza" : { "comment" : "The title of the sheet that allows users to customize and add pizzas to their cart.", "isCommentAutoGenerated" : true + }, + "Motivo do cancelamento" : { + }, "Name" : { "comment" : "Placeholder title for the Name field in a form", @@ -707,9 +713,6 @@ }, "Pedido %@" : { - }, - "Pedido ID #%@" : { - }, "PediFoods" : { "comment" : "The name of the app.", @@ -772,6 +775,9 @@ "Privacidade" : { "comment" : "The title of the privacy policy screen.", "isCommentAutoGenerated" : true + }, + "Progresso do Pedido" : { + }, "Receber Código" : { "comment" : "A button label that says \"Receive Code\".", @@ -876,13 +882,13 @@ "Seu carrinho tem itens de outra loja. Deseja limpar o carrinho atual para adicionar este produto?" : { "comment" : "A message displayed when the user attempts to add a product to their cart from a different store. Asks if the user wants to clear their current cart before adding the new product.", "isCommentAutoGenerated" : true - }, - "Seu código do pedido: %@ - Informe esse número ao motoboy" : { - }, "Seu endereço não é atendido por esta loja.\nDeseja trocar mesmo assim?" : { "comment" : "An alert that appears when a user tries to place an order from an address that is not served by the store. The alert offers the user the option to either keep the current address or to switch to a different one", "isCommentAutoGenerated" : true + }, + "Seu pedido está em andamento" : { + }, "Sim" : { "comment" : "The text for a button that confirms an action. In this case, it confirms the user's choice to continue with the order despite the address not being served by the store.", @@ -982,6 +988,9 @@ "Trocar de loja?" : { "comment" : "A title for an alert that prompts the user to switch stores.", "isCommentAutoGenerated" : true + }, + "Ver motivo do cancelamento" : { + }, "Ver Perfil" : { "comment" : "A button label that translates to \"View Profile\" in English.", diff --git a/pedi-foods/Sources/PediFoods/Resources/tracking-canceled.png b/pedi-foods/Sources/PediFoods/Resources/tracking-canceled.png new file mode 100644 index 0000000..6b0acbc Binary files /dev/null and b/pedi-foods/Sources/PediFoods/Resources/tracking-canceled.png differ diff --git a/pedi-foods/Sources/PediFoods/Resources/tracking-completed.png b/pedi-foods/Sources/PediFoods/Resources/tracking-completed.png new file mode 100644 index 0000000..fee3d7f Binary files /dev/null and b/pedi-foods/Sources/PediFoods/Resources/tracking-completed.png differ diff --git a/pedi-foods/Sources/PediFoods/Resources/tracking-delivering.png b/pedi-foods/Sources/PediFoods/Resources/tracking-delivering.png new file mode 100644 index 0000000..2d996ae Binary files /dev/null and b/pedi-foods/Sources/PediFoods/Resources/tracking-delivering.png differ diff --git a/pedi-foods/Sources/PediFoods/Resources/tracking-pending.png b/pedi-foods/Sources/PediFoods/Resources/tracking-pending.png new file mode 100644 index 0000000..a4a9344 Binary files /dev/null and b/pedi-foods/Sources/PediFoods/Resources/tracking-pending.png differ diff --git a/pedi-foods/Sources/PediFoods/Resources/tracking-preparing.png b/pedi-foods/Sources/PediFoods/Resources/tracking-preparing.png new file mode 100644 index 0000000..23fc9a7 Binary files /dev/null and b/pedi-foods/Sources/PediFoods/Resources/tracking-preparing.png differ diff --git a/pedi-foods/Sources/PediFoods/Resources/tracking-ready.png b/pedi-foods/Sources/PediFoods/Resources/tracking-ready.png new file mode 100644 index 0000000..fe2f0f2 Binary files /dev/null and b/pedi-foods/Sources/PediFoods/Resources/tracking-ready.png differ diff --git a/pedi-foods/Sources/PediFoods/Services/ApiClient.swift b/pedi-foods/Sources/PediFoods/Services/ApiClient.swift index e044f5a..66db1b7 100644 --- a/pedi-foods/Sources/PediFoods/Services/ApiClient.swift +++ b/pedi-foods/Sources/PediFoods/Services/ApiClient.swift @@ -13,6 +13,7 @@ enum NetworkError: Error, LocalizedError { case unauthorized(String?) case decodeError(String?) case rateLimited(Int?) + case cancelled case transportError(String) var errorDescription: String? { @@ -33,6 +34,8 @@ enum NetworkError: Error, LocalizedError { return "Muitas requisicoes. Tente novamente em \(retryAfter)s." } return "Muitas requisicoes. Tente novamente." + case .cancelled: + return "Requisicao cancelada" case .transportError(let message): return "Erro de rede: \(message)" } @@ -82,7 +85,11 @@ final class ApiClient { // NOTE: // /api/customer/login is strict about body fields (email/phoneNumber/otp). // On iOS, routing this endpoint through URLSession ensures JSON body arrives as-is. - if request.path == "/api/customer/login" { + // Also route order-related polling/listing through URLSession to avoid + // intermittent cancellation observed with the shared API bridge. + if request.path == "/api/customer/login" || + request.path == "/api/app/orders" || + request.path.hasPrefix("/api/public/orders/") { if let body = request.body, let bodyText = String(data: body, encoding: .utf8) { print("[ApiClient] /api/customer/login body: \(bodyText)") } @@ -161,7 +168,6 @@ private extension ApiClient { } func mapError(_ error: Error) -> NetworkError { - printError(title: "httpReqError", msg: error.localizedDescription) if let network = error as? NetworkError { return network } @@ -176,6 +182,12 @@ private extension ApiClient { return .unauthorized(payload?.message ?? apiMessage) } + if nsError.domain == NSURLErrorDomain && nsError.code == NSURLErrorCancelled { + return .cancelled + } + + printError(title: "httpReqError", msg: error.localizedDescription) + switch nsError.code { case 401, 403: return .unauthorized(apiMessage) @@ -209,6 +221,8 @@ private extension ApiClient { while attempt <= maxAttempts { do { return try await perform(urlRequest, as: T.self) + } catch is CancellationError { + throw NetworkError.cancelled } catch let error as NetworkError { guard shouldRetry(error), attempt < maxAttempts else { throw error @@ -230,6 +244,9 @@ private extension ApiClient { do { (data, response) = try await session.data(for: request) } catch { + if let urlError = error as? URLError, urlError.code == .cancelled { + throw NetworkError.cancelled + } throw NetworkError.transportError(error.localizedDescription) } @@ -283,7 +300,7 @@ private extension ApiClient { return true case .httpError(let statusCode, _): return statusCode >= 500 - case .invalidURL, .invalidResponse, .decodeError, .unauthorized: + case .invalidURL, .invalidResponse, .decodeError, .unauthorized, .cancelled: return false } } diff --git a/pedi-foods/Sources/PediFoods/Services/ApiOrderModels.swift b/pedi-foods/Sources/PediFoods/Services/ApiOrderModels.swift index 19fdf66..2e255d3 100644 --- a/pedi-foods/Sources/PediFoods/Services/ApiOrderModels.swift +++ b/pedi-foods/Sources/PediFoods/Services/ApiOrderModels.swift @@ -46,6 +46,7 @@ struct CreateOrderResult: Decodable { let shortId: String? let status: String? let paymentStatus: String? + let paymentConfirmed: Bool? let paymentMethod: String? let paymentPayload: CreateOrderPaymentPayload? let payment: CreateOrderPaymentInfo? @@ -55,6 +56,7 @@ struct CreateOrderResult: Decodable { case shortId case status case paymentStatus + case paymentConfirmed case paymentMethod case paymentPayload case payment @@ -62,11 +64,12 @@ struct CreateOrderResult: Decodable { init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) - id = try? container.decode(String.self, forKey: .id) - shortId = try? container.decode(String.self, forKey: .shortId) - status = try? container.decode(String.self, forKey: .status) - paymentStatus = try? container.decode(String.self, forKey: .paymentStatus) - paymentMethod = try? container.decode(String.self, forKey: .paymentMethod) + id = ApiService.decodeFlexibleString(from: container, keys: [.id]) + shortId = ApiService.decodeFlexibleString(from: container, keys: [.shortId]) + status = ApiService.decodeFlexibleString(from: container, keys: [.status]) + paymentStatus = ApiService.decodeFlexibleString(from: container, keys: [.paymentStatus]) + paymentConfirmed = try? container.decode(Bool.self, forKey: .paymentConfirmed) + paymentMethod = ApiService.decodeFlexibleString(from: container, keys: [.paymentMethod]) payment = try? container.decode(CreateOrderPaymentInfo.self, forKey: .payment) if let objectPayload = try? container.decode(CreateOrderPaymentPayload.self, forKey: .paymentPayload) { @@ -83,13 +86,13 @@ struct CreateOrderResult: Decodable { } } -struct CreateOrderPaymentInfo: Decodable { +struct CreateOrderPaymentInfo: Codable { let method: String? let status: String? let pix: CreateOrderPaymentPayload? } -struct CreateOrderPaymentPayload: Decodable { +struct CreateOrderPaymentPayload: Codable { let copyPaste: String? let qrCodeImage: String? let expirationDate: String? @@ -116,6 +119,13 @@ struct CreateOrderPaymentPayload: Decodable { ?? (try? container.decode(String.self, forKey: .encodedImage)) expirationDate = try? container.decode(String.self, forKey: .expirationDate) } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encodeIfPresent(copyPaste, forKey: .copyPaste) + try container.encodeIfPresent(qrCodeImage, forKey: .qrCodeImage) + try container.encodeIfPresent(expirationDate, forKey: .expirationDate) + } } struct ValidateDeliveryAddressPayload: Encodable { diff --git a/pedi-foods/Sources/PediFoods/Services/ApiOrderTrackingModels.swift b/pedi-foods/Sources/PediFoods/Services/ApiOrderTrackingModels.swift index 5f47ab3..2ba9699 100644 --- a/pedi-foods/Sources/PediFoods/Services/ApiOrderTrackingModels.swift +++ b/pedi-foods/Sources/PediFoods/Services/ApiOrderTrackingModels.swift @@ -2,9 +2,14 @@ import Foundation struct AppOrderSummary: Decodable, Identifiable { let id: String + let orderId: String? + let realId: String? let shortId: String? let total: Double? let status: String? + let statusDetailed: String? + let statusLabel: String? + let nextAction: String? let paymentStatus: String? let paymentMethod: String? let deliveryType: String? @@ -14,39 +19,57 @@ struct AppOrderSummary: Decodable, Identifiable { enum CodingKeys: String, CodingKey { case id + case orderId + case realId case shortId case total case status + case statusDetailed + case statusLabel + case nextAction case paymentStatus case paymentMethod case deliveryType case storeName + case date case createdAt case updatedAt } init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) - id = (try? container.decode(String.self, forKey: .id)) ?? UUID().uuidString - shortId = try? container.decode(String.self, forKey: .shortId) + orderId = ApiService.decodeFlexibleString(from: container, keys: [.orderId]) + id = ApiService.decodeFlexibleString(from: container, keys: [.orderId, .realId, .id]) ?? UUID().uuidString + realId = ApiService.decodeFlexibleString(from: container, keys: [.realId]) + shortId = ApiService.decodeFlexibleString(from: container, keys: [.shortId]) total = ApiService.decodeFlexibleDouble(from: container, keys: [.total]) - status = try? container.decode(String.self, forKey: .status) - paymentStatus = try? container.decode(String.self, forKey: .paymentStatus) - paymentMethod = try? container.decode(String.self, forKey: .paymentMethod) - deliveryType = try? container.decode(String.self, forKey: .deliveryType) - storeName = try? container.decode(String.self, forKey: .storeName) - createdAt = try? container.decode(String.self, forKey: .createdAt) - updatedAt = try? container.decode(String.self, forKey: .updatedAt) + status = ApiService.decodeFlexibleString(from: container, keys: [.status]) + statusDetailed = ApiService.decodeFlexibleString(from: container, keys: [.statusDetailed]) + statusLabel = ApiService.decodeFlexibleString(from: container, keys: [.statusLabel]) + nextAction = ApiService.decodeFlexibleString(from: container, keys: [.nextAction]) + paymentStatus = ApiService.decodeFlexibleString(from: container, keys: [.paymentStatus]) + paymentMethod = ApiService.decodeFlexibleString(from: container, keys: [.paymentMethod]) + deliveryType = ApiService.decodeFlexibleString(from: container, keys: [.deliveryType]) + storeName = ApiService.decodeFlexibleString(from: container, keys: [.storeName]) + let fallbackDate = ApiService.decodeFlexibleString(from: container, keys: [.date]) + createdAt = ApiService.decodeFlexibleString(from: container, keys: [.createdAt]) ?? fallbackDate + updatedAt = ApiService.decodeFlexibleString(from: container, keys: [.updatedAt]) ?? fallbackDate } } struct PublicOrderResult: Codable, Identifiable { let id: String let shortId: String? + let realId: String? let status: String? let paymentStatus: String? + let paymentConfirmed: Bool? let paymentMethod: String? + let paymentMethodCode: String? + let paymentPayload: CreateOrderPaymentPayload? + let payment: CreateOrderPaymentInfo? let deliveryType: String? + let deliveryTypeLabel: String? let total: Double? let storeName: String? let createdAt: String? @@ -54,16 +77,23 @@ struct PublicOrderResult: Codable, Identifiable { let otp: String? let customerOtp: String? let confirmOtp: String? + let cancellationReason: String? let items: [PublicOrderItem] let timeline: [PublicOrderTimelineEvent] enum CodingKeys: String, CodingKey { case id case shortId + case realId case status case paymentStatus + case paymentConfirmed case paymentMethod + case paymentMethodCode + case paymentPayload + case payment case deliveryType + case deliveryTypeLabel case total case storeName case createdAt @@ -71,6 +101,7 @@ struct PublicOrderResult: Codable, Identifiable { case otp case customerOtp case confirmOtp + case cancellationReason case items case timeline } @@ -78,10 +109,16 @@ struct PublicOrderResult: Codable, Identifiable { init( id: String, shortId: String? = nil, + realId: String? = nil, status: String? = nil, paymentStatus: String? = nil, + paymentConfirmed: Bool? = nil, paymentMethod: String? = nil, + paymentMethodCode: String? = nil, + paymentPayload: CreateOrderPaymentPayload? = nil, + payment: CreateOrderPaymentInfo? = nil, deliveryType: String? = nil, + deliveryTypeLabel: String? = nil, total: Double? = nil, storeName: String? = nil, createdAt: String? = nil, @@ -89,15 +126,22 @@ struct PublicOrderResult: Codable, Identifiable { otp: String? = nil, customerOtp: String? = nil, confirmOtp: String? = nil, + cancellationReason: String? = nil, items: [PublicOrderItem] = [], timeline: [PublicOrderTimelineEvent] = [] ) { self.id = id self.shortId = shortId + self.realId = realId self.status = status self.paymentStatus = paymentStatus + self.paymentConfirmed = paymentConfirmed self.paymentMethod = paymentMethod + self.paymentMethodCode = paymentMethodCode + self.paymentPayload = paymentPayload + self.payment = payment self.deliveryType = deliveryType + self.deliveryTypeLabel = deliveryTypeLabel self.total = total self.storeName = storeName self.createdAt = createdAt @@ -105,25 +149,43 @@ struct PublicOrderResult: Codable, Identifiable { self.otp = otp self.customerOtp = customerOtp self.confirmOtp = confirmOtp + self.cancellationReason = cancellationReason self.items = items self.timeline = timeline } init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) - id = (try? container.decode(String.self, forKey: .id)) ?? UUID().uuidString - shortId = try? container.decode(String.self, forKey: .shortId) - status = try? container.decode(String.self, forKey: .status) - paymentStatus = try? container.decode(String.self, forKey: .paymentStatus) - paymentMethod = try? container.decode(String.self, forKey: .paymentMethod) - deliveryType = try? container.decode(String.self, forKey: .deliveryType) + id = ApiService.decodeFlexibleString(from: container, keys: [.id]) ?? UUID().uuidString + shortId = ApiService.decodeFlexibleString(from: container, keys: [.shortId]) + realId = ApiService.decodeFlexibleString(from: container, keys: [.realId]) + status = ApiService.decodeFlexibleString(from: container, keys: [.status]) + paymentStatus = ApiService.decodeFlexibleString(from: container, keys: [.paymentStatus]) + paymentConfirmed = try? container.decode(Bool.self, forKey: .paymentConfirmed) + paymentMethod = ApiService.decodeFlexibleString(from: container, keys: [.paymentMethod]) + paymentMethodCode = ApiService.decodeFlexibleString(from: container, keys: [.paymentMethodCode]) + payment = try? container.decode(CreateOrderPaymentInfo.self, forKey: .payment) + if let objectPayload = try? container.decode(CreateOrderPaymentPayload.self, forKey: .paymentPayload) { + paymentPayload = objectPayload + } else if let stringPayload = try? container.decode(String.self, forKey: .paymentPayload) { + paymentPayload = CreateOrderPaymentPayload( + copyPaste: stringPayload, + qrCodeImage: nil, + expirationDate: nil + ) + } else { + paymentPayload = nil + } + deliveryType = ApiService.decodeFlexibleString(from: container, keys: [.deliveryType]) + deliveryTypeLabel = ApiService.decodeFlexibleString(from: container, keys: [.deliveryTypeLabel]) total = ApiService.decodeFlexibleDouble(from: container, keys: [.total]) - storeName = try? container.decode(String.self, forKey: .storeName) - createdAt = try? container.decode(String.self, forKey: .createdAt) - updatedAt = try? container.decode(String.self, forKey: .updatedAt) - otp = try? container.decode(String.self, forKey: .otp) - customerOtp = try? container.decode(String.self, forKey: .customerOtp) - confirmOtp = try? container.decode(String.self, forKey: .confirmOtp) + storeName = ApiService.decodeFlexibleString(from: container, keys: [.storeName]) + createdAt = ApiService.decodeFlexibleString(from: container, keys: [.createdAt]) + updatedAt = ApiService.decodeFlexibleString(from: container, keys: [.updatedAt]) + otp = ApiService.decodeFlexibleString(from: container, keys: [.otp]) + customerOtp = ApiService.decodeFlexibleString(from: container, keys: [.customerOtp]) + confirmOtp = ApiService.decodeFlexibleString(from: container, keys: [.confirmOtp]) + cancellationReason = ApiService.decodeFlexibleString(from: container, keys: [.cancellationReason]) items = (try? container.decode([PublicOrderItem].self, forKey: .items)) ?? [] timeline = (try? container.decode([PublicOrderTimelineEvent].self, forKey: .timeline)) ?? [] } @@ -152,6 +214,10 @@ struct PublicOrderResult: Codable, Identifiable { } var isPaymentConfirmed: Bool { + if let paymentConfirmed { + return paymentConfirmed + } + let payment = (paymentStatus ?? "").uppercased() let currentStatus = (status ?? "").uppercased() if Self.looksConfirmed(payment) || Self.looksConfirmed(currentStatus) { @@ -226,21 +292,38 @@ struct PublicOrderItem: Codable, Identifiable { struct PublicOrderTimelineEvent: Codable, Identifiable { let id: String let status: String? + let label: String? + let active: Bool? + let completed: Bool? let message: String? let time: String? enum CodingKeys: String, CodingKey { case id case status + case label + case active + case completed case message case time case createdAt case updatedAt } - init(id: String = UUID().uuidString, status: String?, message: String?, time: String?) { + init( + id: String = UUID().uuidString, + status: String?, + label: String? = nil, + active: Bool? = nil, + completed: Bool? = nil, + message: String?, + time: String? + ) { self.id = id self.status = status + self.label = label + self.active = active + self.completed = completed self.message = message self.time = time } @@ -249,6 +332,9 @@ struct PublicOrderTimelineEvent: Codable, Identifiable { let container = try decoder.container(keyedBy: CodingKeys.self) id = (try? container.decode(String.self, forKey: .id)) ?? UUID().uuidString status = try? container.decode(String.self, forKey: .status) + label = try? container.decode(String.self, forKey: .label) + active = try? container.decode(Bool.self, forKey: .active) + completed = try? container.decode(Bool.self, forKey: .completed) message = try? container.decode(String.self, forKey: .message) time = (try? container.decode(String.self, forKey: .time)) ?? (try? container.decode(String.self, forKey: .createdAt)) @@ -259,6 +345,9 @@ struct PublicOrderTimelineEvent: Codable, Identifiable { var container = encoder.container(keyedBy: CodingKeys.self) try container.encode(id, forKey: .id) try container.encodeIfPresent(status, forKey: .status) + try container.encodeIfPresent(label, forKey: .label) + try container.encodeIfPresent(active, forKey: .active) + try container.encodeIfPresent(completed, forKey: .completed) try container.encodeIfPresent(message, forKey: .message) try container.encodeIfPresent(time, forKey: .time) } @@ -272,6 +361,27 @@ struct OrderRealtimeUpdate: Decodable { let status: String? let paymentStatus: String? let updatedAt: String? + + enum CodingKeys: String, CodingKey { + case id + case shortId + case storeId + case userId + case status + case paymentStatus + case updatedAt + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = ApiService.decodeFlexibleString(from: container, keys: [.id]) + shortId = ApiService.decodeFlexibleString(from: container, keys: [.shortId]) + storeId = ApiService.decodeFlexibleString(from: container, keys: [.storeId]) + userId = ApiService.decodeFlexibleString(from: container, keys: [.userId]) + status = ApiService.decodeFlexibleString(from: container, keys: [.status]) + paymentStatus = ApiService.decodeFlexibleString(from: container, keys: [.paymentStatus]) + updatedAt = ApiService.decodeFlexibleString(from: container, keys: [.updatedAt]) + } } extension CreateOrderResult { @@ -281,7 +391,10 @@ extension CreateOrderResult { shortId: shortId, status: status, paymentStatus: paymentStatus, - paymentMethod: paymentMethod + paymentConfirmed: paymentConfirmed, + paymentMethod: paymentMethod, + paymentPayload: paymentPayload, + payment: payment ) } } diff --git a/pedi-foods/Sources/PediFoods/Services/ApiService.swift b/pedi-foods/Sources/PediFoods/Services/ApiService.swift index 988504c..8c6883e 100644 --- a/pedi-foods/Sources/PediFoods/Services/ApiService.swift +++ b/pedi-foods/Sources/PediFoods/Services/ApiService.swift @@ -21,6 +21,9 @@ struct ApiEnvelope: Decodable { final class ApiService { private let client: ApiClient private var tokenStore: TokenStore + private let profileCachePrefix = "api:profile:" + private let ordersCachePrefix = "api:orders:" + private let publicCategoriesCacheKey = "api:public-categories" init(client: ApiClient = ApiClient(), tokenStore: TokenStore = DefaultTokenStore()) { self.client = client @@ -63,9 +66,18 @@ final class ApiService { private func expireSession(_ message: String?) { tokenStore.clear() + AppContentCache.shared.invalidate(prefix: profileCachePrefix) + AppContentCache.shared.invalidate(prefix: ordersCachePrefix) + AppContentCache.shared.invalidate(prefix: publicCategoriesCacheKey) NotificationCenter.default.post(name: .sessionExpired, object: message) } + private func scopedCacheSuffix() -> String { + let jwt = tokenStore.jwt ?? "anonymous" + if jwt.count <= 16 { return jwt } + return String(jwt.prefix(16)) + } + // MARK: - Auth func registerCustomer(name: String, email: String, phoneNumber: String, birthDate: String? = nil) async throws -> ApiEnvelope { @@ -121,9 +133,19 @@ final class ApiService { return try JSONSerialization.data(withJSONObject: payload, options: []) } - func profile() async throws -> ApiEnvelope { + func profile(forceRefresh: Bool = false) async throws -> ApiEnvelope { + let cacheKey = "\(profileCachePrefix)\(scopedCacheSuffix())" + if forceRefresh == false, + let cached: ApiEnvelope = AppContentCache.shared.value(for: cacheKey, as: ApiEnvelope.self) { + return cached + } + let req = ApiRequest(path: "/api/customer/profile", method: "GET", module: .customer, requiresAuth: true) - return try await sendEnvelope(req) + let envelope: ApiEnvelope = try await sendEnvelope(req) + if envelope.error == false, envelope.result != nil { + AppContentCache.shared.set(envelope, for: cacheKey, ttl: AppCacheTTL.twoHours) + } + return envelope } func addCustomerAddress(_ address: CustomerAddress) async throws -> ApiEnvelope { @@ -131,7 +153,7 @@ final class ApiService { } func saveCustomerAddress(_ address: CustomerAddress, replacingAddressId: String?) async throws -> ApiEnvelope { - let currentProfile = try await profile() + let currentProfile = try await profile(forceRefresh: true) guard currentProfile.error == false, let customer = currentProfile.result else { throw NetworkError.invalidResponse } @@ -151,7 +173,7 @@ final class ApiService { } func deleteCustomerAddress(_ address: CustomerAddress) async throws -> ApiEnvelope { - let currentProfile = try await profile() + let currentProfile = try await profile(forceRefresh: true) guard currentProfile.error == false, let customer = currentProfile.result else { throw NetworkError.invalidResponse } @@ -189,7 +211,14 @@ final class ApiService { let payload = CustomerProfileUpdatePayload(addressBook: addressBook) let body = try JSONEncoder().encode(payload) let req = ApiRequest(path: "/api/customer/\(customerId)", method: "POST", module: .customer, requiresAuth: true, body: body) - return try await sendEnvelope(req) + let envelope: ApiEnvelope = try await sendEnvelope(req) + let scopedProfileKey = "\(profileCachePrefix)\(scopedCacheSuffix())" + if envelope.error == false, envelope.result != nil { + AppContentCache.shared.set(envelope, for: scopedProfileKey, ttl: AppCacheTTL.twoHours) + } else { + AppContentCache.shared.invalidate(prefix: profileCachePrefix) + } + return envelope } private func matchesAddress(_ lhs: CustomerAddress, _ rhs: CustomerAddress) -> Bool { @@ -205,9 +234,18 @@ final class ApiService { // MARK: - Stores - func listPublicCategories() async throws -> ApiEnvelope<[PublicCategory]> { + func listPublicCategories(forceRefresh: Bool = false) async throws -> ApiEnvelope<[PublicCategory]> { + if forceRefresh == false, + let cached: ApiEnvelope<[PublicCategory]> = AppContentCache.shared.value(for: publicCategoriesCacheKey, as: ApiEnvelope<[PublicCategory]>.self) { + return cached + } + let req = ApiRequest(path: "/api/public/categories", method: "GET", module: .none, requiresAuth: false) - return try await sendEnvelope(req) + let envelope: ApiEnvelope<[PublicCategory]> = try await sendEnvelope(req) + if envelope.error == false { + AppContentCache.shared.set(envelope, for: publicCategoriesCacheKey, ttl: AppCacheTTL.twoHours) + } + return envelope } func listStores(lat: Double? = nil, lng: Double? = nil, category: String? = nil, search: String? = nil) async throws -> ApiEnvelope<[StoreSummary]> { @@ -239,7 +277,11 @@ final class ApiService { func createOrder(storeId: String, payload: CreateOrderPayload) async throws -> ApiEnvelope { let body = try JSONEncoder().encode(payload) let req = ApiRequest(path: "/api/store/\(storeId)/orders", method: "POST", module: .store, requiresAuth: true, body: body) - return try await sendEnvelope(req) + let envelope: ApiEnvelope = try await sendEnvelope(req) + if envelope.error == false { + AppContentCache.shared.invalidate(prefix: ordersCachePrefix) + } + return envelope } func validateDeliveryAddress(storeId: String, payload: ValidateDeliveryAddressPayload) async throws -> ApiEnvelope { @@ -248,9 +290,19 @@ final class ApiService { return try await sendEnvelope(req) } - func listOrders() async throws -> ApiEnvelope<[AppOrderSummary]> { + func listOrders(forceRefresh: Bool = false) async throws -> ApiEnvelope<[AppOrderSummary]> { + let cacheKey = "\(ordersCachePrefix)\(scopedCacheSuffix())" + if forceRefresh == false, + let cached: ApiEnvelope<[AppOrderSummary]> = AppContentCache.shared.value(for: cacheKey, as: ApiEnvelope<[AppOrderSummary]>.self) { + return cached + } + let req = ApiRequest(path: "/api/app/orders", method: "GET", module: .app, requiresAuth: true) - return try await sendEnvelope(req) + let envelope: ApiEnvelope<[AppOrderSummary]> = try await sendEnvelope(req) + if envelope.error == false { + AppContentCache.shared.set(envelope, for: cacheKey, ttl: AppCacheTTL.twoHours) + } + return envelope } func publicOrder(orderId: String) async throws -> ApiEnvelope { @@ -259,6 +311,27 @@ final class ApiService { } } extension ApiService { + static func decodeFlexibleString(from container: KeyedDecodingContainer, keys: [K]) -> String? { + for key in keys { + if let value = try? container.decode(String.self, forKey: key) { + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.isEmpty == false { + return trimmed + } + } + if let asInt = try? container.decode(Int.self, forKey: key) { + return String(asInt) + } + if let asDouble = try? container.decode(Double.self, forKey: key) { + if asDouble.rounded() == asDouble { + return String(Int(asDouble)) + } + return String(asDouble) + } + } + return nil + } + static func decodeFlexibleDouble(from container: KeyedDecodingContainer, keys: [K]) -> Double? { for key in keys { if let value = try? container.decode(Double.self, forKey: key) { diff --git a/pedi-foods/Sources/PediFoods/Services/AppCache.swift b/pedi-foods/Sources/PediFoods/Services/AppCache.swift index a001cef..d199bf0 100644 --- a/pedi-foods/Sources/PediFoods/Services/AppCache.swift +++ b/pedi-foods/Sources/PediFoods/Services/AppCache.swift @@ -7,6 +7,11 @@ import AppKit typealias PlatformImage = NSImage #endif +enum AppCacheTTL { + static let twoHours: TimeInterval = 2 * 60 * 60 + static let homeStores: TimeInterval = 5 * 60 +} + final class AppContentCache: @unchecked Sendable { static let shared = AppContentCache() diff --git a/pedi-foods/Sources/PediFoods/Services/OrderRealtimeTracker.swift b/pedi-foods/Sources/PediFoods/Services/OrderRealtimeTracker.swift index a1cd507..fd68a02 100644 --- a/pedi-foods/Sources/PediFoods/Services/OrderRealtimeTracker.swift +++ b/pedi-foods/Sources/PediFoods/Services/OrderRealtimeTracker.swift @@ -5,6 +5,9 @@ import FoundationNetworking @MainActor final class OrderRealtimeTracker { + // Keep realtime tracking on polling to avoid socket.io handshake failures + // on environments where websocket upgrade is not available. + private let useSocketRealtime = false private var pollingTask: Task? = nil private var socketClient: OrderSocketClient? = nil private var activeOrderId: String? = nil @@ -20,7 +23,7 @@ final class OrderRealtimeTracker { await self.runPollingLoop(orderId: orderId) } - guard let jwt, jwt.isEmpty == false else { return } + guard useSocketRealtime, let jwt, jwt.isEmpty == false else { return } let socket = OrderSocketClient() socket.onOrderUpdate = { [weak self] update in guard let self else { return } @@ -76,12 +79,42 @@ final class OrderRealtimeTracker { let response = try await ApiService().publicOrder(orderId: orderId) guard response.error == false, let order = response.result else { return nil } SessionStateStore.saveTrackedOrder(order) + clearPendingCartIfNeeded(for: order) onOrderUpdated?(order) return order } catch { return nil } } + + private func clearPendingCartIfNeeded(for order: PublicOrderResult) { + guard let pendingId = SessionStateStore.loadPendingCartOrderId() else { return } + let normalizedPending = pendingId.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + if normalizedPending.isEmpty { return } + + let ids = [order.id, order.realId] + .compactMap { $0?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() } + guard ids.contains(normalizedPending) else { return } + + if shouldClearCart(for: order) == false { return } + + SessionStateStore.clearCart() + SessionStateStore.clearPendingCartOrder() + NotificationCenter.default.post(name: .cartDidReset, object: nil) + } + + private func shouldClearCart(for order: PublicOrderResult) -> Bool { + if order.isPaymentConfirmed { + return true + } + + let status = (order.status ?? "").uppercased() + if status.contains("COMPLETED") || status.contains("DELIVERED") || status.contains("RECEIVED") { + return true + } + + return false + } } final class OrderSocketClient: @unchecked Sendable { diff --git a/pedi-foods/Sources/PediFoods/Services/SessionEvents.swift b/pedi-foods/Sources/PediFoods/Services/SessionEvents.swift index 7402ab1..593e531 100644 --- a/pedi-foods/Sources/PediFoods/Services/SessionEvents.swift +++ b/pedi-foods/Sources/PediFoods/Services/SessionEvents.swift @@ -2,5 +2,5 @@ import Foundation extension Notification.Name { static let sessionExpired = Notification.Name("SessionExpiredNotification") + static let cartDidReset = Notification.Name("CartDidResetNotification") } - diff --git a/pedi-foods/Sources/PediFoods/Services/SessionStateStore.swift b/pedi-foods/Sources/PediFoods/Services/SessionStateStore.swift index 419f6a5..fd92220 100644 --- a/pedi-foods/Sources/PediFoods/Services/SessionStateStore.swift +++ b/pedi-foods/Sources/PediFoods/Services/SessionStateStore.swift @@ -43,6 +43,7 @@ enum SessionStateStore { private static let activeUserKey = "session.active.user.v1" private static let cartKeyPrefix = "session.cart.state.v1." private static let trackedOrdersKeyPrefix = "session.orders.tracking.v1." + private static let pendingCartOrderKeyPrefix = "session.cart.pending-order.v1." static func makeUserKey(profileId: String?, email: String?) -> String? { let id = (profileId ?? "") @@ -253,6 +254,13 @@ enum SessionStateStore { return trackedOrdersKeyPrefix + safe } + private static func pendingCartOrderStorageKey(for userKey: String?) -> String { + let key = (userKey ?? loadActiveUserKey() ?? "anonymous") + .trimmingCharacters(in: .whitespacesAndNewlines) + let safe = key.replacingOccurrences(of: " ", with: "_") + return pendingCartOrderKeyPrefix + safe + } + static func loadTrackedOrders() -> [PublicOrderResult] { let defaults = UserDefaults.standard let key = trackedOrdersStorageKey(for: nil) @@ -285,4 +293,26 @@ enum SessionStateStore { static func clearTrackedOrders() { UserDefaults.standard.removeObject(forKey: trackedOrdersStorageKey(for: nil)) } + + static func savePendingCartOrderId(_ orderId: String) { + let clean = orderId.trimmingCharacters(in: .whitespacesAndNewlines) + guard clean.isEmpty == false else { + clearPendingCartOrder() + return + } + UserDefaults.standard.set(clean, forKey: pendingCartOrderStorageKey(for: nil)) + } + + static func loadPendingCartOrderId() -> String? { + let value = UserDefaults.standard.string(forKey: pendingCartOrderStorageKey(for: nil))? + .trimmingCharacters(in: .whitespacesAndNewlines) + if let value, value.isEmpty == false { + return value + } + return nil + } + + static func clearPendingCartOrder() { + UserDefaults.standard.removeObject(forKey: pendingCartOrderStorageKey(for: nil)) + } } diff --git a/pedi-foods/Sources/PediFoods/Views/Auth/AuthFormatters.swift b/pedi-foods/Sources/PediFoods/Views/Auth/AuthFormatters.swift index 6a73e39..cf300fa 100644 --- a/pedi-foods/Sources/PediFoods/Views/Auth/AuthFormatters.swift +++ b/pedi-foods/Sources/PediFoods/Views/Auth/AuthFormatters.swift @@ -77,6 +77,8 @@ func userFacingAuthErrorMessage(_ error: Error) -> String { return "Não foi possível se conectar ao servidor. Tente novamente." case .invalidURL, .invalidResponse, .decodeError: return "Ocorreu uma instabilidade ao processar sua solicitação. Tente novamente." + case .cancelled: + return "Cancelado" } } diff --git a/pedi-foods/Sources/PediFoods/Views/Auth/OtpView.swift b/pedi-foods/Sources/PediFoods/Views/Auth/OtpView.swift index b2d8f48..32a9e22 100644 --- a/pedi-foods/Sources/PediFoods/Views/Auth/OtpView.swift +++ b/pedi-foods/Sources/PediFoods/Views/Auth/OtpView.swift @@ -1,4 +1,7 @@ import SwiftUI +#if canImport(UIKit) +import UIKit +#endif struct OtpView: View { private let resendDelaySeconds = 45 @@ -66,6 +69,10 @@ struct OtpView: View { .frame(height: 204) .onTapGesture { isOtpFocused = true + autoFillOtpFromClipboardIfAvailable() + } + .onLongPressGesture { + pasteOtpFromClipboard() } .padding(.horizontal, 24) .padding(.top, 26) @@ -83,6 +90,13 @@ struct OtpView: View { } .padding(.top, 22) + Button("Colar código") { + pasteOtpFromClipboard() + } + .font(AppTypography.heading3) + .foregroundStyle(AppColors.primary) + .padding(.top, 22) + PrimaryButton(title: "Verificar e Entrar") { validateOtp() } @@ -166,6 +180,34 @@ struct OtpView: View { return Array(otp)[index] } + private func pasteOtpFromClipboard() { +#if canImport(UIKit) + let raw = UIPasteboard.general.string ?? "" + let digits = raw.filter(\.isNumber) + let trimmed = String(digits.prefix(8)) + if trimmed.isEmpty == false { + otp = trimmed + } +#elseif canImport(AppKit) + let raw = NSPasteboard.general.string(forType: .string) ?? "" + let digits = raw.filter(\.isNumber) + let trimmed = String(digits.prefix(8)) + if trimmed.isEmpty == false { + otp = trimmed + } +#endif + } + + private func autoFillOtpFromClipboardIfAvailable() { +#if canImport(UIKit) + guard otp.isEmpty else { return } + let raw = UIPasteboard.general.string ?? "" + let digits = raw.filter(\.isNumber) + guard digits.count >= 8 else { return } + otp = String(digits.prefix(8)) +#endif + } + private func validateOtp() { let code = otp.filter(\.isNumber) guard code.count == 8 else { return } diff --git a/pedi-foods/Sources/PediFoods/Views/Main/CartView.swift b/pedi-foods/Sources/PediFoods/Views/Main/CartView.swift index 9212929..ffd02e2 100644 --- a/pedi-foods/Sources/PediFoods/Views/Main/CartView.swift +++ b/pedi-foods/Sources/PediFoods/Views/Main/CartView.swift @@ -7,7 +7,9 @@ struct CartView: View { @State var couponCode = "" @State var appliedCouponCode: String? = nil @State var discountValue: Double = 0 - @State var deliveryFee: Double = 5 + @State var deliveryFee: Double? = nil + @State var selectedCustomerAddress: CustomerAddress? = nil + @State var isLoadingDeliveryFee = false var body: some View { ScrollView(showsIndicators: false) { @@ -50,6 +52,9 @@ struct CartView: View { .navigationDestination(isPresented: $openCheckout) { CheckoutView(appState: $appState) } + .task(id: deliveryFeeWatchKey) { + await refreshDeliveryFee() + } } private var subtotalValue: Double { @@ -57,7 +62,16 @@ struct CartView: View { } private var totalValue: Double { - max(0, subtotalValue + deliveryFee - discountValue) + max(0, subtotalValue + (deliveryFee ?? 0) - discountValue) + } + + private var deliveryFeeWatchKey: String { + let storeId = appState.cart.storeId ?? "nil" + let selectedId = appState.address.selectedId ?? "nil" + let display = appState.address.display.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + let lat = appState.address.latitude.map { String(format: "%.5f", $0) } ?? "nil" + let lng = appState.address.longitude.map { String(format: "%.5f", $0) } ?? "nil" + return "\(storeId)|\(selectedId)|\(display)|\(lat)|\(lng)" } private var couponSection: some View { @@ -108,7 +122,7 @@ struct CartView: View { .foregroundStyle(AppColors.textPrimary) summaryRow(title: "Subtotal", value: formatCurrency(subtotalValue)) - summaryRow(title: "Taxa de Entrega", value: formatCurrency(deliveryFee)) + summaryRow(title: "Taxa de Entrega", value: deliveryFeeLabel) summaryRow(title: "Desconto", value: "-\(formatCurrency(discountValue))", valueColor: Color.red) Divider() @@ -136,6 +150,16 @@ struct CartView: View { .clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusXL, style: .continuous)) } + private var deliveryFeeLabel: String { + if isLoadingDeliveryFee { + return "Calculando..." + } + if let deliveryFee { + return formatCurrency(deliveryFee) + } + return "Indisponível" + } + private func summaryRow(title: String, value: String, valueColor: Color? = nil, highlighted: Bool = false) -> some View { HStack { Text(title) @@ -230,4 +254,88 @@ struct CartView: View { private func formatCurrency(_ value: Double) -> String { String(format: "R$ %.2f", value).replacingOccurrences(of: ".", with: ",") } + + @MainActor + private func refreshDeliveryFee() async { + guard appState.cart.items.isEmpty == false else { + deliveryFee = nil + selectedCustomerAddress = nil + return + } + guard let storeId = appState.cart.storeId, storeId.isEmpty == false else { + deliveryFee = nil + selectedCustomerAddress = nil + return + } + + isLoadingDeliveryFee = true + defer { isLoadingDeliveryFee = false } + + do { + let profileResponse = try await ApiService().profile() + let addresses = profileResponse.result?.addressBook ?? [] + + if let selectedId = appState.address.selectedId, selectedId.isEmpty == false { + selectedCustomerAddress = addresses.first(where: { $0.id == selectedId }) + } else { + selectedCustomerAddress = nil + } + + if selectedCustomerAddress == nil { + let display = appState.address.display + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() + if display.isEmpty == false, display != "defina seu endereco" { + selectedCustomerAddress = addresses.first { + ($0.label ?? "").trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == display + } + } + } + + if selectedCustomerAddress == nil { + selectedCustomerAddress = addresses.first + } + + if let selected = selectedCustomerAddress { + appState.address.selectedId = selected.id + let label = (selected.label ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + if label.isEmpty == false { + appState.address.display = label + } + if let lat = selected.latLong?.first, let lng = selected.latLong?.dropFirst().first { + appState.address.latitude = lat + appState.address.longitude = lng + } + SessionStateStore.saveAddress(appState.address) + } + + let payloadLat = selectedCustomerAddress?.latLong?.first ?? appState.address.latitude + let payloadLng = selectedCustomerAddress?.latLong?.dropFirst().first ?? appState.address.longitude + + let payload = ValidateDeliveryAddressPayload( + address: ValidateDeliveryAddressDataPayload( + street: selectedCustomerAddress?.address, + number: selectedCustomerAddress?.number, + neighborhood: selectedCustomerAddress?.neighborhood, + city: selectedCustomerAddress?.city, + state: selectedCustomerAddress?.state, + zip: selectedCustomerAddress?.zipCode, + lat: payloadLat, + lng: payloadLng + ) + ) + + let validationResponse = try await ApiService().validateDeliveryAddress(storeId: storeId, payload: payload) + guard validationResponse.error == false, + validationResponse.result?.deliveryAllowed == true, + let fee = validationResponse.result?.deliveryFee else { + deliveryFee = nil + return + } + + deliveryFee = fee + } catch { + deliveryFee = nil + } + } } diff --git a/pedi-foods/Sources/PediFoods/Views/Main/CheckoutView+Logic.swift b/pedi-foods/Sources/PediFoods/Views/Main/CheckoutView+Logic.swift index 2564304..469747f 100644 --- a/pedi-foods/Sources/PediFoods/Views/Main/CheckoutView+Logic.swift +++ b/pedi-foods/Sources/PediFoods/Views/Main/CheckoutView+Logic.swift @@ -260,11 +260,23 @@ extension CheckoutView { SessionStateStore.saveTrackedOrder(orderSnapshot) let orderId = result.id ?? UUID().uuidString - if useInAppPayment == false { + let isInAppMethod = availableInAppPaymentMethods.contains(effectivePaymentMethod) + if useInAppPayment == false || isInAppMethod == false { + appState.cart.clear() + SessionStateStore.clearPendingCartOrder() orderTrackingContext = OrderTrackingContext(orderId: orderId, shortId: result.shortId) return } + if orderSnapshot.isPaymentConfirmed { + appState.cart.clear() + SessionStateStore.clearPendingCartOrder() + orderTrackingContext = OrderTrackingContext(orderId: orderId, shortId: result.shortId) + return + } + + SessionStateStore.savePendingCartOrderId(orderId) + if effectivePaymentMethod == .creditCard { cardPaymentContext = CardPaymentContext( orderId: orderId, diff --git a/pedi-foods/Sources/PediFoods/Views/Main/CheckoutView.swift b/pedi-foods/Sources/PediFoods/Views/Main/CheckoutView.swift index 7295236..d9f8ec3 100644 --- a/pedi-foods/Sources/PediFoods/Views/Main/CheckoutView.swift +++ b/pedi-foods/Sources/PediFoods/Views/Main/CheckoutView.swift @@ -152,12 +152,28 @@ struct CheckoutView: View { } } .navigationDestination(item: $pixPaymentContext) { context in - PaymentPixView(context: context) { + PaymentPixView( + context: context, + onPaymentConfirmed: { + appState.cart.clear() + SessionStateStore.clearPendingCartOrder() + } + ) { + appState.cart.clear() + SessionStateStore.clearPendingCartOrder() orderTrackingContext = OrderTrackingContext(orderId: context.orderId, shortId: context.shortId) } } .navigationDestination(item: $cardPaymentContext) { context in - PaymentCardView(context: context) { + PaymentCardView( + context: context, + onPaymentConfirmed: { + appState.cart.clear() + SessionStateStore.clearPendingCartOrder() + } + ) { + appState.cart.clear() + SessionStateStore.clearPendingCartOrder() orderTrackingContext = OrderTrackingContext(orderId: context.orderId, shortId: context.shortId) } } @@ -552,11 +568,14 @@ struct CardPaymentContext: Identifiable, Hashable { struct PaymentPixView: View { let context: PixPaymentContext + var onPaymentConfirmed: (() -> Void)? = nil var onOpenTracking: (() -> Void)? = nil @Environment(\.dismiss) var dismiss @State var tracker = OrderRealtimeTracker() @State var latestOrder: PublicOrderResult? = nil @State var hasOpenedTracking = false + @State var hasShownPixExpiredSnackbar = false + @State var currentTime = Date() private var qrImageSource: String? { guard let raw = context.qrCodeImageBase64?.trimmingCharacters(in: .whitespacesAndNewlines), @@ -590,51 +609,51 @@ struct PaymentPixView: View { Text("AGUARDANDO PAGAMENTO") .font(AppTypography.heading3) .foregroundStyle(AppColors.textPrimary) - .padding(.horizontal, 12) + .padding(.horizontal, 20) .padding(.vertical, 8) .background(AppColors.brandSoft) .clipShape(Capsule()) } ) - .frame(maxWidth: .infinity, minHeight: 360) + .frame(maxWidth: .infinity, minHeight: 380) + Text("Código PIX") + .font(AppTypography.heading3) + .foregroundStyle(AppColors.textPrimary) + RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous) .fill(AppColors.surface) + .frame(height: 20) .overlay( VStack(spacing: 8) { - Text("Código PIX") - .font(AppTypography.heading3) - .foregroundStyle(AppColors.textPrimary) Text(context.copyPaste) .font(.system(size: 12, weight: .medium, design: .monospaced)) .foregroundStyle(AppColors.textMuted) - .lineLimit(3) + .lineLimit(0) .multilineTextAlignment(.center) - .padding(.horizontal, 12) - if let expirationDate = context.expirationDate, expirationDate.isEmpty == false { - Text("Expira em: \(expirationDate)") - .font(.caption) - .foregroundStyle(AppColors.textMuted) - } + .padding(.horizontal, 10) + } .padding(.vertical, 14) ) - + + if let expirationDate = context.expirationDate, expirationDate.isEmpty == false { + Text(expirationLabel) + .font(.caption) + .foregroundStyle(isPixExpired ? Color.red : AppColors.textMuted) + } + PrimaryButton(title: "Copiar Código PIX") { + if isPixExpired { + showPixExpiredSnackbar() + return + } copyToClipboard(context.copyPaste) SnackbarCenter.shared.show(title: "Código PIX copiado.", style: .success, icon: "doc.on.doc.fill", duration: 2.0) } - - Button("Já realizei o pagamento") { - if latestOrder?.isPaymentConfirmed == true { - openTrackingOnce() - } else { - SnackbarCenter.shared.show(title: "Pagamento ainda não confirmado.", style: .info, icon: "clock.fill", duration: 2.0) - } - } - .font(AppTypography.heading3) - .foregroundStyle(AppColors.textMuted) - .padding(.top, 8) + .disabled(isPixExpired) + .opacity(isPixExpired ? 0.5 : 1.0) + .padding(.top, 10) } .padding(20) } @@ -645,11 +664,22 @@ struct PaymentPixView: View { tracker.onOrderUpdated = { updated in latestOrder = updated if updated.isPaymentConfirmed { + onPaymentConfirmed?() openTrackingOnce() } } tracker.start(orderId: context.orderId, jwt: DefaultTokenStore().jwt) } + .task { + while Task.isCancelled == false { + currentTime = Date() + if isPixExpired { + showPixExpiredSnackbar() + return + } + try? await Task.sleep(nanoseconds: 1_000_000_000) + } + } .onDisappear { tracker.stop() } @@ -659,7 +689,6 @@ struct PaymentPixView: View { guard hasOpenedTracking == false else { return } hasOpenedTracking = true onOpenTracking?() - dismiss() } private func copyToClipboard(_ value: String) { @@ -670,10 +699,83 @@ struct PaymentPixView: View { NSPasteboard.general.setString(value, forType: .string) #endif } + + private var parsedExpirationDate: Date? { + let raw = (context.expirationDate ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + guard raw.isEmpty == false else { return nil } + + let iso = ISO8601DateFormatter() + iso.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + if let date = iso.date(from: raw) { return date } + iso.formatOptions = [.withInternetDateTime] + if let date = iso.date(from: raw) { return date } + + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "pt_BR") + let formats = [ + "yyyy-MM-dd HH:mm:ss", + "yyyy-MM-dd'T'HH:mm:ss", + "yyyy-MM-dd'T'HH:mm:ss.SSSZ", + "dd/MM/yyyy HH:mm:ss", + "dd/MM/yyyy HH:mm" + ] + for format in formats { + formatter.dateFormat = format + if let date = formatter.date(from: raw) { + return date + } + } + + return nil + } + + private var isPixExpired: Bool { + guard let parsedExpirationDate else { return false } + return currentTime >= parsedExpirationDate + } + + private var expirationLabel: String { + guard let parsedExpirationDate else { + return "Expira em: --" + } + if isPixExpired { + return "Expirado" + } + + let remaining = max(0, Int(parsedExpirationDate.timeIntervalSince(currentTime))) + let day = 24 * 60 * 60 + let hour = 60 * 60 + + if remaining >= day { + let days = remaining / day + return "Expira em: \(days) dia(s)" + } + if remaining >= hour { + let hours = remaining / hour + return "Expira em: \(hours) hora(s)" + } + if remaining >= 60 { + let minutes = remaining / 60 + return "Expira em: \(minutes) min" + } + return "Vai expirar em \(remaining) segundos" + } + + private func showPixExpiredSnackbar() { + guard hasShownPixExpiredSnackbar == false else { return } + hasShownPixExpiredSnackbar = true + SnackbarCenter.shared.show( + title: "PIX expirou. Gere um novo pedido para continuar.", + style: .warning, + icon: "clock.badge.xmark.fill", + duration: 4.0 + ) + } } struct PaymentCardView: View { let context: CardPaymentContext + var onPaymentConfirmed: (() -> Void)? = nil var onOpenTracking: (() -> Void)? = nil @Environment(\.dismiss) var dismiss @State var cardHolderName = "" @@ -723,6 +825,7 @@ struct PaymentCardView: View { PrimaryButton(title: "Salvar e Pagar") { if latestOrder?.isPaymentConfirmed == true { + onPaymentConfirmed?() openTrackingOnce() } else { SnackbarCenter.shared.show(title: "Pagamento ainda não confirmado.", style: .info, icon: "clock.fill", duration: 2.0) @@ -731,6 +834,7 @@ struct PaymentCardView: View { Button("Apenas Pagar") { if latestOrder?.isPaymentConfirmed == true { + onPaymentConfirmed?() openTrackingOnce() } else { SnackbarCenter.shared.show(title: "Aguardando confirmação do pagamento.", style: .info, icon: "clock.fill", duration: 2.0) @@ -752,6 +856,7 @@ struct PaymentCardView: View { tracker.onOrderUpdated = { updated in latestOrder = updated if updated.isPaymentConfirmed { + onPaymentConfirmed?() openTrackingOnce() } } @@ -766,7 +871,6 @@ struct PaymentCardView: View { guard hasOpenedTracking == false else { return } hasOpenedTracking = true onOpenTracking?() - dismiss() } private func labeledField(_ label: String, placeholder: String, text: Binding) -> some View { diff --git a/pedi-foods/Sources/PediFoods/Views/Main/HomeView+Data.swift b/pedi-foods/Sources/PediFoods/Views/Main/HomeView+Data.swift index 777a723..02d2df3 100644 --- a/pedi-foods/Sources/PediFoods/Views/Main/HomeView+Data.swift +++ b/pedi-foods/Sources/PediFoods/Views/Main/HomeView+Data.swift @@ -20,18 +20,29 @@ extension HomeView { } @MainActor - func loadHomeCategories(withFallbackStores stores: [StoreSummary]) async { + func loadHomeCategories(withFallbackStores stores: [StoreSummary], forceRefresh: Bool = false) async { + let cacheKey = "public-categories" + if forceRefresh == false, + let cached: [CategoryModel] = AppContentCache.shared.value(for: cacheKey, as: [CategoryModel].self) { + categories = cached + return + } + do { - let response = try await ApiService().listPublicCategories() + let response = try await ApiService().listPublicCategories(forceRefresh: forceRefresh) if response.error == false, let remote = response.result, remote.isEmpty == false { - categories = mapPublicCategories(remote) + let mapped = mapPublicCategories(remote) + categories = mapped + AppContentCache.shared.set(mapped, for: cacheKey, ttl: AppCacheTTL.twoHours) return } } catch { // Fallback handled below. } - categories = buildCategories(from: stores) + let fallback = buildCategories(from: stores) + categories = fallback + AppContentCache.shared.set(fallback, for: cacheKey, ttl: AppCacheTTL.twoHours) } func mapPublicCategories(_ remote: [PublicCategory]) -> [CategoryModel] { diff --git a/pedi-foods/Sources/PediFoods/Views/Main/HomeView.swift b/pedi-foods/Sources/PediFoods/Views/Main/HomeView.swift index ac32edf..a823d3f 100644 --- a/pedi-foods/Sources/PediFoods/Views/Main/HomeView.swift +++ b/pedi-foods/Sources/PediFoods/Views/Main/HomeView.swift @@ -83,10 +83,6 @@ struct HomeView: View { .onChange(of: addressCacheScope) { _, _ in guard hasRequestedLocation else { return } Task { - AppContentCache.shared.invalidate(prefix: "stores:") - AppContentCache.shared.invalidate(prefix: "store-info:") - AppContentCache.shared.invalidate(prefix: "store-catalog:") - AppImageCache.shared.invalidateAll() await bootstrapStoresFlow( forceLocationRefresh: true, category: selectedCategory == "all" ? nil : selectedCategory, @@ -435,7 +431,7 @@ struct HomeView: View { isLoadingStores = false stores = cachedStores if refreshCategories || (category == nil && categories.count <= 1) { - await loadHomeCategories(withFallbackStores: cachedStores) + await loadHomeCategories(withFallbackStores: cachedStores, forceRefresh: forceLocationRefresh) if categories.contains(where: { $0.id == selectedCategory }) == false { selectedCategory = "all" } @@ -457,9 +453,9 @@ struct HomeView: View { } let results = response.result ?? [] stores = results - AppContentCache.shared.set(results, for: storesCacheKey, ttl: 180) + AppContentCache.shared.set(results, for: storesCacheKey, ttl: AppCacheTTL.homeStores) if refreshCategories || (category == nil && categories.count <= 1) { - await loadHomeCategories(withFallbackStores: results) + await loadHomeCategories(withFallbackStores: results, forceRefresh: forceLocationRefresh) if categories.contains(where: { $0.id == selectedCategory }) == false { selectedCategory = "all" } diff --git a/pedi-foods/Sources/PediFoods/Views/Main/OrderTrackingView.swift b/pedi-foods/Sources/PediFoods/Views/Main/OrderTrackingView.swift index c73cb42..e52004c 100644 --- a/pedi-foods/Sources/PediFoods/Views/Main/OrderTrackingView.swift +++ b/pedi-foods/Sources/PediFoods/Views/Main/OrderTrackingView.swift @@ -1,5 +1,14 @@ import SwiftUI +private struct TrackingStep: Identifiable { + let id: String + let title: String + let subtitle: String + let time: String? + let isCompleted: Bool + let isActive: Bool +} + struct OrderTrackingView: View { let orderId: String let initialShortId: String? @@ -8,38 +17,30 @@ struct OrderTrackingView: View { @State var errorMessage: String? = nil @State var order: PublicOrderResult? = nil @State var tracker = OrderRealtimeTracker() + @State var showCancellationReason = false var body: some View { ScrollView(showsIndicators: false) { VStack(spacing: 16) { - headerCard - - if isLoading { - ProgressView() - .padding(.top, 20) - } - - if let errorMessage, errorMessage.isEmpty == false { - Text(errorMessage) - .font(AppTypography.body) - .foregroundStyle(Color.red) - .multilineTextAlignment(.center) - .padding(.horizontal, 20) - } - - if let order { - timelineCard(order) - } - - footerPlaceholderCard + topHeader + orderTitleSection + statusBanner + timelineSection + placeholderCard + contactButton } .padding(.horizontal, 20) .padding(.top, 14) - .padding(.bottom, UIDevice.bottomNotch + 24) + .padding(.bottom, UIDevice.bottomNotch + 45) } .background(AppColors.backgroundLight) .navigationTitle("Pedido \(displayOrderTitle)") .navigationBarTitleDisplayMode(.inline) + .alert("Motivo do cancelamento", isPresented: $showCancellationReason) { + Button("Fechar", role: .cancel) {} + } message: { + Text(cancellationReasonText) + } .task { await loadInitialOrder() tracker.onOrderUpdated = { updated in @@ -54,67 +55,103 @@ struct OrderTrackingView: View { } } - private var displayOrderTitle: String { - if let short = order?.shortId, short.isEmpty == false { return "#\(short)" } - if let initialShortId, initialShortId.isEmpty == false { return "#\(initialShortId)" } - return "#\(orderId.prefix(6))" + private var topHeader: some View { + HStack(spacing: 10) { + Circle() + .fill(Color.white.opacity(0.2)) + .frame(width: 28, height: 28) + .overlay( + Circle() + .fill(Color.white.opacity(0.35)) + .frame(width: 14, height: 14) + ) + Text("Acompanhamento em tempo real") + .font(AppTypography.heading2) + .foregroundStyle(Color.white) + Spacer() + } + .padding(.horizontal, 16) + .padding(.vertical, 18) + .background(AppColors.primary) + .clipShape(RoundedRectangle(cornerRadius: 28, style: .continuous)) } - private var headerCard: some View { + private var orderTitleSection: some View { VStack(alignment: .leading, spacing: 6) { - Text(statusTitle) + Text(order?.storeName?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false ? (order?.storeName ?? "Seu pedido") : "Seu pedido") .font(AppTypography.heading1) .foregroundStyle(AppColors.textPrimary) - Text("Pedido ID #\(order?.shortId ?? initialShortId ?? orderId)") + Text("Pedido #\(displayOrderTitle)") .font(AppTypography.body) .foregroundStyle(AppColors.textMuted) - - if let order, order.isInDeliveryRoute, let otp = order.displayOtpCode { - Text("Seu código do pedido: \(otp) - Informe esse número ao motoboy") - .font(AppTypography.body) - .foregroundStyle(AppColors.textPrimary) - .multilineTextAlignment(.leading) - .padding(.top, 2) - } - - HStack { - Spacer() - Text(statusPill) - .font(.system(size: 13, weight: .bold)) - .foregroundStyle(AppColors.primary) - .padding(.horizontal, 12) - .padding(.vertical, 6) - .background(AppColors.brandSoft) - .clipShape(Capsule()) - } } .frame(maxWidth: .infinity, alignment: .leading) - .padding(16) - .background(AppColors.surface) - .clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous)) } - private func timelineCard(_ order: PublicOrderResult) -> some View { - let events: [PublicOrderTimelineEvent] = { - if order.timeline.isEmpty == false { return order.timeline } - return [ - PublicOrderTimelineEvent( - status: order.status ?? order.paymentStatus ?? "PENDING", - message: nil, - time: order.updatedAt ?? order.createdAt + @ViewBuilder + private var statusBanner: some View { + if let errorMessage, errorMessage.isEmpty == false { + statusBadge( + title: errorMessage, + fg: Color.red, + bg: Color.red.opacity(0.12), + icon: "xmark.octagon.fill" + ) + } else if isLoading { + HStack(spacing: 10) { + ProgressView() + Text("Atualizando status do pedido...") + .font(AppTypography.body) + .foregroundStyle(AppColors.textMuted) + } + .frame(maxWidth: .infinity, alignment: .leading) + } else if isCanceled { + VStack(alignment: .leading, spacing: 10) { + statusBadge( + title: "Pedido cancelado", + fg: Color.red, + bg: Color.red.opacity(0.12), + icon: "xmark.circle.fill" ) - ] - }() + if cancellationReasonText.isEmpty == false { + Button("Ver motivo do cancelamento") { + showCancellationReason = true + } + .font(AppTypography.heading3) + .foregroundStyle(Color.red) + .buttonStyle(.plain) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } else if isWaitingPayment { + statusBadge( + title: "Aguardando pagamento", + fg: Color(hex: "#A16207"), + bg: Color(hex: "#FDE68A").opacity(0.35), + icon: "clock.fill" + ) + } else { + statusBadge( + title: successBannerTitle, + fg: AppColors.primary, + bg: AppColors.brandSoft, + icon: "checkmark.circle.fill" + ) + } + } - return VStack(alignment: .leading, spacing: 12) { - Text("Acompanhamento") + private var timelineSection: some View { + VStack(alignment: .leading, spacing: 14) { + Text("Progresso do Pedido") .font(AppTypography.heading2) .foregroundStyle(AppColors.textPrimary) - VStack(alignment: .leading, spacing: 0) { - ForEach(Array(events.enumerated()), id: \.offset) { index, event in - timelineRow(event: event, isLast: index == events.count - 1) + if let order { + VStack(alignment: .leading, spacing: 0) { + ForEach(Array(timelineSteps(for: order).enumerated()), id: \.element.id) { index, step in + timelineRow(step: step, isLast: index == timelineSteps(for: order).count - 1) + } } } } @@ -124,112 +161,473 @@ struct OrderTrackingView: View { .clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous)) } - private func timelineRow(event: PublicOrderTimelineEvent, isLast: Bool) -> some View { + private var placeholderCard: some View { + VStack(spacing: 12) { + HStack(spacing: 10) { + Circle() + .fill(AppColors.brandSoft) + .frame(width: 28, height: 28) + .overlay( + Image(systemName: summaryStatusIcon) + .font(.system(size: 12, weight: .bold)) + .foregroundStyle(AppColors.primary) + ) + Text(summaryStatusTitle) + .font(AppTypography.heading3) + .foregroundStyle(AppColors.textPrimary) + Spacer() + } + + Group { + if hasTrackingImage { + Image(trackingImageName) + .renderingMode(.original) + .resizable() + .scaledToFit() + } else if hasPlaceholderProductImage { + Image("placeholder-product") + .renderingMode(.original) + .resizable() + .scaledToFit() + } else { + ZStack { + Color.black.opacity(0.08) + Image(systemName: "shippingbox.fill") + .font(.system(size: 52, weight: .bold)) + .foregroundStyle(AppColors.primary) + } + } + } + .frame(height: 220) + .clipShape(RoundedRectangle(cornerRadius: 18, style: .continuous)) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(16) + .background(AppColors.surface) + .clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous)) + } + + private var contactButton: some View { + Button("CONTATO") {} + .font(AppTypography.heading2) + .foregroundStyle(AppColors.textPrimary) + .frame(maxWidth: .infinity, minHeight: 56) + .background(AppColors.surface) + .clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous)) + .buttonStyle(.plain) + } + + private func statusBadge(title: String, fg: Color, bg: Color, icon: String) -> some View { + HStack(spacing: 8) { + Image(systemName: icon) + .font(.system(size: 14, weight: .bold)) + Text(title) + .font(AppTypography.heading3) + } + .foregroundStyle(fg) + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background(bg) + .clipShape(Capsule()) + .frame(maxWidth: .infinity, alignment: .leading) + } + + private func timelineRow(step: TrackingStep, isLast: Bool) -> some View { HStack(alignment: .top, spacing: 12) { VStack(spacing: 0) { Circle() - .fill(AppColors.tertiary) + .fill(stepDotColor(step)) .frame(width: 20, height: 20) .overlay( - Image(systemName: "checkmark") - .font(.system(size: 11, weight: .bold)) - .foregroundStyle(.white) + Group { + if step.isCompleted { + Image(systemName: "checkmark") + .font(.system(size: 11, weight: .bold)) + .foregroundStyle(.white) + } else if step.isActive { + Circle() + .fill(.white) + .frame(width: 8, height: 8) + } else { + Circle() + .stroke(Color(hex: "#C5CBD4"), lineWidth: 2) + .frame(width: 8, height: 8) + } + } ) if isLast == false { Rectangle() - .fill(AppColors.tertiary.opacity(0.45)) - .frame(width: 2, height: 30) + .fill(stepLineColor(step)) + .frame(width: 2, height: 36) } } VStack(alignment: .leading, spacing: 2) { - Text(humanReadableStatus(event.status ?? event.message ?? "Atualizado")) - .font(AppTypography.heading3) - .foregroundStyle(AppColors.textPrimary) - Text(formatTime(event.time) ?? "") - .font(AppTypography.caption) - .foregroundStyle(AppColors.textMuted) - } + Text(step.title) + .font(AppTypography.heading2) + .foregroundStyle(stepTitleColor(step)) + if step.subtitle.isEmpty == false { + Text(step.subtitle) + .font(AppTypography.body) + .foregroundStyle(stepSubtitleColor(step)) + } + + if let time = step.time, time.isEmpty == false { + Text(time) + .font(AppTypography.caption) + .foregroundStyle(AppColors.textMuted) + } + } Spacer() } } - private var footerPlaceholderCard: some View { - VStack(spacing: 10) { - RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous) - .fill(Color.black.opacity(0.9)) - .frame(height: 180) - .overlay( - Image(systemName: "shippingbox.fill") - .font(.system(size: 56, weight: .bold)) - .foregroundStyle(AppColors.tertiary) - ) - - Text("Acompanhe seu pedido em tempo real") - .font(AppTypography.body) - .foregroundStyle(AppColors.textMuted) + private func stepDotColor(_ step: TrackingStep) -> Color { + if isCanceled { + return Color(hex: "#C5CBD4") } - .frame(maxWidth: .infinity) - .padding(16) - .background(AppColors.surface) - .clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous)) + if isWaitingPayment && step.id == "paid" { + return Color(hex: "#F59E0B") + } + if step.isCompleted || step.isActive { + return AppColors.primary + } + return Color(hex: "#E5E7EB") } - private var statusTitle: String { - humanReadableStatus(order?.status ?? "Aguardando") + private func stepLineColor(_ step: TrackingStep) -> Color { + if isCanceled { return Color(hex: "#E5E7EB") } + if step.isCompleted || step.isActive { + return AppColors.primary.opacity(0.85) + } + return Color(hex: "#E5E7EB") } - private var statusPill: String { - let value = order?.paymentStatus ?? order?.status ?? "PENDING" - return humanReadableStatus(value) + private func stepTitleColor(_ step: TrackingStep) -> Color { + if isCanceled { return Color(hex: "#9CA3AF") } + if step.isCompleted || step.isActive { + return AppColors.textPrimary + } + return Color(hex: "#9CA3AF") } - private func humanReadableStatus(_ raw: String) -> String { - let normalized = raw.uppercased() - if normalized.contains("PAYMENT_PENDING") || normalized == "PENDING" { - return "Aguardando pagamento" + private func stepSubtitleColor(_ step: TrackingStep) -> Color { + if isCanceled { return Color(hex: "#9CA3AF") } + if isWaitingPayment && step.id == "paid" { + return Color(hex: "#A16207") } - if normalized.contains("CONFIRMED") { - return "Pagamento confirmado" + if step.isCompleted || step.isActive { + return AppColors.primary } - if normalized.contains("PREPAR") { - return "Em preparo" + return Color(hex: "#9CA3AF") + } + + private func timelineSteps(for order: PublicOrderResult) -> [TrackingStep] { + let isPickup = normalized(order.deliveryType ?? order.deliveryTypeLabel).contains("PICKUP") + let allSteps: [(id: String, title: String, subtitle: String, statuses: [String])] = [ + ("confirmed", "Pedido confirmado", "Seu pedido foi recebido", ["PENDING", "ACCEPTED", "PAYMENT_PENDING"]), + ("preparing", "Pedido em produção", "Seu pedido está sendo preparado", ["PREPARING"]), + ("ready", isPickup ? "Pronto para retirada" : "Pronto para entrega", isPickup ? "Retire no balcão quando avisarmos" : "Pedido pronto para sair", ["READY"]), + ("delivering", "Em rota de entrega", customerOtpSubtitle, ["DELIVERING", "OUT_FOR_DELIVERY", "EM_ROTA", "ON_ROUTE"]), + ("completed", isPickup ? "Retirado" : "Entregue", "Pedido finalizado", ["COMPLETED", "DELIVERED"]) + ] + + let stepsBase = isPickup ? allSteps.filter { $0.id != "delivering" } : allSteps + let currentIndex = currentStepIndex(isPickup: isPickup) + + return stepsBase.enumerated().map { index, step in + let event = timelineEvent(for: order, statuses: step.statuses) + let isCompleted = event?.completed ?? (isCanceled == false && index < currentIndex) + let isActive = event?.active ?? (isCanceled == false && index == currentIndex) + return TrackingStep( + id: step.id, + title: step.title, + subtitle: timelineSubtitle(stepId: step.id, fallback: step.subtitle, eventLabel: event?.label), + time: formatTime(event?.time), + isCompleted: isCompleted || (index == currentIndex && isCompletedTerminalStep(index: index, isPickup: isPickup)), + isActive: isActive && isCompletedTerminalStep(index: index, isPickup: isPickup) == false + ) } - if normalized.contains("OUT_FOR_DELIVERY") || normalized.contains("EM_ROTA") || normalized.contains("ROTA") { - return "Em rota de entrega" + } + + private func currentStepIndex(isPickup: Bool) -> Int { + let normalizedStatus = normalized(order?.status) + + if isCanceled { + if normalizedStatus.contains("DELIVER") || normalizedStatus.contains("ROTA") { return isPickup ? 2 : 3 } + if normalizedStatus.contains("READY") { return 2 } + if normalizedStatus.contains("PREPAR") || normalizedStatus.contains("ACCEPT") || normalizedStatus.contains("PENDING") { return 1 } + return 0 } - if normalized.contains("COMPLETED") || normalized.contains("DELIVERED") { + + if isWaitingPayment { + return 0 + } + + if let timelineIndex = timelineProgressStepIndex(isPickup: isPickup) { + return timelineIndex + } + + if normalizedStatus.contains("COMPLETED") || normalizedStatus.contains("DELIVERED") { + return isPickup ? 3 : 4 + } + if isPickup { + if normalizedStatus.contains("READY") { return 2 } + if normalizedStatus.contains("PREPAR") { return 1 } + return 0 + } + + if normalizedStatus.contains("DELIVER") || normalizedStatus.contains("ROTA") { + return 3 + } + if normalizedStatus.contains("READY") { + return 2 + } + if normalizedStatus.contains("PREPAR") { + return 1 + } + return 0 + } + + private func timelineProgressStepIndex(isPickup: Bool) -> Int? { + guard let order else { return nil } + + let stepStatuses: [[String]] = isPickup + ? [ + ["PENDING", "ACCEPTED", "PAYMENT_PENDING"], + ["PREPARING"], + ["READY"], + ["COMPLETED", "DELIVERED"] + ] + : [ + ["PENDING", "ACCEPTED", "PAYMENT_PENDING"], + ["PREPARING"], + ["READY"], + ["DELIVERING", "OUT_FOR_DELIVERY", "EM_ROTA", "ON_ROUTE"], + ["COMPLETED", "DELIVERED"] + ] + + var strongestIndex: Int? = nil + var fallbackIndex: Int? = nil + + for (index, statuses) in stepStatuses.enumerated() { + let statusSet = Set(statuses.map(normalized)) + let events = order.timeline.filter { event in + statusSet.contains(normalized(event.status)) + } + guard events.isEmpty == false else { continue } + + fallbackIndex = index + + if events.contains(where: { $0.active == true || $0.completed == true }) { + strongestIndex = index + } + } + + return strongestIndex ?? fallbackIndex + } + + private func isCompletedTerminalStep(index: Int, isPickup: Bool) -> Bool { + let terminalIndex = isPickup ? 3 : 4 + return isCanceled == false && currentStepIndex(isPickup: isPickup) >= terminalIndex && index == terminalIndex + } + + private func timelineEvent(for order: PublicOrderResult, statuses: [String]) -> PublicOrderTimelineEvent? { + let statusSet = Set(statuses.map(normalized)) + return order.timeline.first(where: { statusSet.contains(normalized($0.status)) }) + } + + private func timelineSubtitle(stepId: String, fallback: String, eventLabel: String?) -> String { + if stepId == "delivering", customerOtpCode != nil { + return customerOtpSubtitle + } + let label = (eventLabel ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + if label.isEmpty == false { + return label + } + return fallback + } + + private var customerOtpSubtitle: String { + if let otp = customerOtpCode { + return "Código para o entregador: \(otp)" + } + return "Aguardando saída para entrega" + } + + private var customerOtpCode: String? { + let raw = (order?.customerOtp ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + if raw.isEmpty { return nil } + let digits = raw.filter(\.isNumber) + if digits.count == 4 { + return digits + } + return nil + } + + private var displayOrderTitle: String { + if let short = order?.shortId, short.isEmpty == false { return short } + if let orderIdValue = order?.id, orderIdValue.isEmpty == false { return orderIdValue } + if let initialShortId, initialShortId.isEmpty == false { return initialShortId } + return String(orderId.prefix(6)) + } + + private var isCanceled: Bool { + normalized(order?.status).contains("CANCEL") + } + + private var isWaitingPayment: Bool { + let paymentStatus = normalized(order?.paymentStatus) + if isOnlinePaymentMethod == false { + return false + } + if paymentStatus == "PENDING" { + return true + } + return order?.isPaymentConfirmed == false + } + + private var isOnlinePaymentMethod: Bool { + let code = normalized(order?.paymentMethodCode) + if code == "PIX" || code == "CREDIT_CARD" { + return true + } + return false + } + + private var successBannerTitle: String { + if isCompletedOrder { + if isPickupOrder { + return "Pedido retirado" + } return "Pedido entregue" } - if normalized.contains("CANCEL") { - return "Pedido cancelado" + if isOnlinePaymentMethod { + return "Pagamento confirmado" } - return raw.capitalized + return "Pedido confirmado" } - private func formatTime(_ isoValue: String?) -> String? { - guard let isoValue, isoValue.isEmpty == false else { return nil } + private var summaryStatusTitle: String { + if isCanceled { + return "Seu pedido foi cancelado" + } + if isWaitingPayment { + return "Aguardando confirmação de pagamento" + } + if isCompletedOrder { + return isPickupOrder ? "Seu pedido foi retirado" : "Seu pedido foi entregue" + } + return "Seu pedido está em andamento" + } + private var summaryStatusIcon: String { + if isCanceled { + return "xmark" + } + if isWaitingPayment { + return "clock.fill" + } + return "checkmark" + } + + private var isPickupOrder: Bool { + normalized(order?.deliveryType ?? order?.deliveryTypeLabel).contains("PICKUP") + } + + private var isCompletedOrder: Bool { + let status = normalized(order?.status) + if status.contains("COMPLETED") || status.contains("DELIVERED") { + return true + } + + let stepIndex = currentStepIndex(isPickup: isPickupOrder) + let terminalIndex = isPickupOrder ? 3 : 4 + return isCanceled == false && isWaitingPayment == false && stepIndex >= terminalIndex + } + + private var cancellationReasonText: String { + let value = (order?.cancellationReason ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + return value.isEmpty ? "Sem detalhe informado." : value + } + + private var hasTrackingImage: Bool { + imageResourceExists(trackingImageName) + } + + private var hasPlaceholderProductImage: Bool { + imageResourceExists("placeholder-product") + } + + private func imageResourceExists(_ name: String) -> Bool { + let exts = ["png", "jpg", "jpeg", "webp"] + for ext in exts { + if Bundle.main.url(forResource: name, withExtension: ext) != nil { + return true + } + if Bundle.module.url(forResource: name, withExtension: ext) != nil { + return true + } + } + return false + } + + private var trackingImageName: String { + let isPickup = normalized(order?.deliveryType ?? order?.deliveryTypeLabel).contains("PICKUP") + let stepIndex = currentStepIndex(isPickup: isPickup) + + if isCanceled { + return "tracking-canceled" + } + if isWaitingPayment { + return "tracking-pending" + } + switch stepIndex { + case 0: + return "tracking-pending" + case 1: + return "tracking-preparing" + case 2: + return isPickup ? "tracking-ready" : "tracking-preparing" + case 3: + return "tracking-delivering" + default: + return "tracking-completed" + } + } + + private func normalized(_ value: String?) -> String { + (value ?? "") + .folding(options: [.diacriticInsensitive, .caseInsensitive], locale: .current) + .uppercased() + .trimmingCharacters(in: .whitespacesAndNewlines) + } + + private func formatTime(_ rawValue: String?) -> String? { + guard let rawValue else { return nil } + let value = rawValue.trimmingCharacters(in: .whitespacesAndNewlines) + if value.isEmpty { return nil } + + if value.contains("T"), let isoTime = formatISOTime(value) { + return isoTime + } + if value.range(of: #"^\d{2}:\d{2}"#, options: .regularExpression) != nil { + return String(value.prefix(5)) + } + return value + } + + private func formatISOTime(_ value: String) -> String? { let iso = ISO8601DateFormatter() iso.formatOptions = [.withInternetDateTime, .withFractionalSeconds] - - var date: Date? = iso.date(from: isoValue) + var date = iso.date(from: value) if date == nil { iso.formatOptions = [.withInternetDateTime] - date = iso.date(from: isoValue) + date = iso.date(from: value) } - - if date == nil { - let fallback = DateFormatter() - fallback.locale = Locale(identifier: "pt_BR") - fallback.dateFormat = "yyyy-MM-dd HH:mm:ss" - date = fallback.date(from: isoValue) - } - guard let date else { return nil } - let formatter = DateFormatter() formatter.locale = Locale(identifier: "pt_BR") formatter.dateFormat = "HH:mm" diff --git a/pedi-foods/Sources/PediFoods/Views/Main/OrdersView.swift b/pedi-foods/Sources/PediFoods/Views/Main/OrdersView.swift index ab7bdd7..7dad9d5 100644 --- a/pedi-foods/Sources/PediFoods/Views/Main/OrdersView.swift +++ b/pedi-foods/Sources/PediFoods/Views/Main/OrdersView.swift @@ -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() + } +} diff --git a/pedi-foods/Sources/PediFoods/Views/Main/StoreDetailView+Logic.swift b/pedi-foods/Sources/PediFoods/Views/Main/StoreDetailView+Logic.swift index a7cfc30..35cbbbc 100644 --- a/pedi-foods/Sources/PediFoods/Views/Main/StoreDetailView+Logic.swift +++ b/pedi-foods/Sources/PediFoods/Views/Main/StoreDetailView+Logic.swift @@ -124,9 +124,9 @@ extension StoreDetailView { categories = catalogResponse.result ?? [] selectedCategoryId = categories.first?.id if let info = infoResponse.result { - AppContentCache.shared.set(info, for: infoCacheKey, ttl: 300) + AppContentCache.shared.set(info, for: infoCacheKey, ttl: AppCacheTTL.twoHours) } - AppContentCache.shared.set(categories, for: catalogCacheKey, ttl: 300) + AppContentCache.shared.set(categories, for: catalogCacheKey, ttl: AppCacheTTL.twoHours) isLoading = false } catch { isLoading = false diff --git a/reference_img/caracters/canceled_order.png b/reference_img/caracters/canceled_order.png new file mode 100644 index 0000000..3348e9e Binary files /dev/null and b/reference_img/caracters/canceled_order.png differ diff --git a/reference_img/caracters/converted/tracking-canceled.png b/reference_img/caracters/converted/tracking-canceled.png new file mode 100644 index 0000000..6b0acbc Binary files /dev/null and b/reference_img/caracters/converted/tracking-canceled.png differ diff --git a/reference_img/caracters/converted/tracking-completed.png b/reference_img/caracters/converted/tracking-completed.png new file mode 100644 index 0000000..fee3d7f Binary files /dev/null and b/reference_img/caracters/converted/tracking-completed.png differ diff --git a/reference_img/caracters/converted/tracking-delivering.png b/reference_img/caracters/converted/tracking-delivering.png new file mode 100644 index 0000000..2d996ae Binary files /dev/null and b/reference_img/caracters/converted/tracking-delivering.png differ diff --git a/reference_img/caracters/converted/tracking-pending.png b/reference_img/caracters/converted/tracking-pending.png new file mode 100644 index 0000000..a4a9344 Binary files /dev/null and b/reference_img/caracters/converted/tracking-pending.png differ diff --git a/reference_img/caracters/converted/tracking-preparing.png b/reference_img/caracters/converted/tracking-preparing.png new file mode 100644 index 0000000..23fc9a7 Binary files /dev/null and b/reference_img/caracters/converted/tracking-preparing.png differ diff --git a/reference_img/caracters/converted/tracking-ready.png b/reference_img/caracters/converted/tracking-ready.png new file mode 100644 index 0000000..fe2f0f2 Binary files /dev/null and b/reference_img/caracters/converted/tracking-ready.png differ diff --git a/reference_img/caracters/delivered_order.png b/reference_img/caracters/delivered_order.png new file mode 100644 index 0000000..0b5ad40 Binary files /dev/null and b/reference_img/caracters/delivered_order.png differ diff --git a/reference_img/caracters/delivering_order.png b/reference_img/caracters/delivering_order.png new file mode 100644 index 0000000..f3291ad Binary files /dev/null and b/reference_img/caracters/delivering_order.png differ diff --git a/reference_img/caracters/pending_order.png b/reference_img/caracters/pending_order.png new file mode 100644 index 0000000..2db91b0 Binary files /dev/null and b/reference_img/caracters/pending_order.png differ diff --git a/reference_img/caracters/preparing_order.png b/reference_img/caracters/preparing_order.png new file mode 100644 index 0000000..6e466b5 Binary files /dev/null and b/reference_img/caracters/preparing_order.png differ diff --git a/reference_img/caracters/ready_order.png b/reference_img/caracters/ready_order.png new file mode 100644 index 0000000..44b72eb Binary files /dev/null and b/reference_img/caracters/ready_order.png differ