From 3a3dc7217b945c61b56e7bb755ee14b1aa46c60d Mon Sep 17 00:00:00 2001 From: Daniel Arantes Loverde Date: Sat, 21 Feb 2026 18:51:39 -0300 Subject: [PATCH] Fix login --- pedi-foods/.gitea/workflows/ci.yml | 45 +++ pedi-foods/.gitea/workflows/pr-validation.yml | 42 +++ .../Sources/PediFoods/ContentView.swift | 13 +- .../PediFoods/Resources/Localizable.xcstrings | 31 +- .../PediFoods/Services/ApiConfig.swift | 4 +- .../Services/ApiOrderTrackingModels.swift | 287 ++++++++++++++++++ .../PediFoods/Services/ApiService.swift | 10 + .../Services/OrderRealtimeTracker.swift | 215 +++++++++++++ .../Services/SessionStateStore.swift | 45 +++ .../PediFoods/Views/Auth/OtpView.swift | 40 +-- .../Views/Main/AddressComponents.swift | 13 - .../Views/Main/CheckoutView+Logic.swift | 66 ++-- .../PediFoods/Views/Main/CheckoutView.swift | 97 +++++- .../PediFoods/Views/Main/HomeView+Data.swift | 10 +- .../Views/Main/OrderTrackingView.swift | 263 ++++++++++++++++ .../PediFoods/Views/Main/OrdersView.swift | 205 +++++++++++++ .../PediFoods/Views/Main/ProfileView.swift | 1 + 17 files changed, 1296 insertions(+), 91 deletions(-) create mode 100644 pedi-foods/.gitea/workflows/ci.yml create mode 100644 pedi-foods/.gitea/workflows/pr-validation.yml create mode 100644 pedi-foods/Sources/PediFoods/Services/ApiOrderTrackingModels.swift create mode 100644 pedi-foods/Sources/PediFoods/Services/OrderRealtimeTracker.swift create mode 100644 pedi-foods/Sources/PediFoods/Views/Main/OrderTrackingView.swift create mode 100644 pedi-foods/Sources/PediFoods/Views/Main/OrdersView.swift diff --git a/pedi-foods/.gitea/workflows/ci.yml b/pedi-foods/.gitea/workflows/ci.yml new file mode 100644 index 0000000..a5f7ca7 --- /dev/null +++ b/pedi-foods/.gitea/workflows/ci.yml @@ -0,0 +1,45 @@ +name: CI + +on: + push: + branches: + - main + - master + - develop + - 'release/**' + workflow_dispatch: + +jobs: + build-and-test: + name: Build and Unit Tests + runs-on: + - self-hosted + - macos + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Configure private dependencies (optional) + if: ${{ secrets.GIT_LOVERDE_TOKEN != '' }} + shell: bash + run: | + git config --global url."https://oauth2:${{ secrets.GIT_LOVERDE_TOKEN }}@git.loverde.com.br/".insteadOf "https://git.loverde.com.br/" + + - name: Toolchain info + shell: bash + run: | + swift --version + xcodebuild -version || true + + - name: Resolve packages + shell: bash + run: swift package resolve + + - name: Build (with tests) + shell: bash + run: swift build --build-tests + + - name: Run unit tests + shell: bash + run: swift test --parallel diff --git a/pedi-foods/.gitea/workflows/pr-validation.yml b/pedi-foods/.gitea/workflows/pr-validation.yml new file mode 100644 index 0000000..d884c33 --- /dev/null +++ b/pedi-foods/.gitea/workflows/pr-validation.yml @@ -0,0 +1,42 @@ +name: PR Validation + +on: + pull_request: + types: [opened, synchronize, reopened, ready_for_review] + workflow_dispatch: + +jobs: + pr-checks: + name: PR Build and Unit Tests + if: ${{ github.event.pull_request.draft == false || github.event_name == 'workflow_dispatch' }} + runs-on: + - self-hosted + - macos + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Configure private dependencies (optional) + if: ${{ secrets.GIT_LOVERDE_TOKEN != '' }} + shell: bash + run: | + git config --global url."https://oauth2:${{ secrets.GIT_LOVERDE_TOKEN }}@git.loverde.com.br/".insteadOf "https://git.loverde.com.br/" + + - name: Toolchain info + shell: bash + run: | + swift --version + xcodebuild -version || true + + - name: Resolve packages + shell: bash + run: swift package resolve + + - name: Build (with tests) + shell: bash + run: swift build --build-tests + + - name: Run unit tests + shell: bash + run: swift test --parallel diff --git a/pedi-foods/Sources/PediFoods/ContentView.swift b/pedi-foods/Sources/PediFoods/ContentView.swift index 28f2221..e808642 100644 --- a/pedi-foods/Sources/PediFoods/ContentView.swift +++ b/pedi-foods/Sources/PediFoods/ContentView.swift @@ -135,6 +135,8 @@ struct ContentView: View { let addresses = customer.addressBook ?? [] guard addresses.isEmpty == false else { + appState.address = AddressState() + SessionStateStore.clearAddress() return } @@ -151,13 +153,14 @@ struct ContentView: View { let cleanLabel = (address.label ?? "") .trimmingCharacters(in: .whitespacesAndNewlines) - if cleanLabel.isEmpty == false { - appState.address.display = cleanLabel - } + appState.address.display = cleanLabel.isEmpty ? "Defina seu endereco" : cleanLabel if let lat = address.latLong?.first, let lng = address.latLong?.dropFirst().first { appState.address.latitude = lat appState.address.longitude = lng + } else { + appState.address.latitude = nil + appState.address.longitude = nil } } @@ -165,6 +168,7 @@ struct ContentView: View { private func forceLogoutToStart() { tokenStore.clear() SessionStateStore.clearActiveUser() + SessionStateStore.clearTrackedOrders() AppContentCache.shared.invalidate() AppImageCache.shared.invalidateAll() isBootstrappingSession = false @@ -177,9 +181,6 @@ struct ContentView: View { if appState.address.selectedId != nil { return true } - if appState.address.latitude != nil, appState.address.longitude != nil { - return true - } let normalized = appState.address.display .trimmingCharacters(in: .whitespacesAndNewlines) .lowercased() diff --git a/pedi-foods/Sources/PediFoods/Resources/Localizable.xcstrings b/pedi-foods/Sources/PediFoods/Resources/Localizable.xcstrings index ce30a55..35193af 100644 --- a/pedi-foods/Sources/PediFoods/Resources/Localizable.xcstrings +++ b/pedi-foods/Sources/PediFoods/Resources/Localizable.xcstrings @@ -77,6 +77,12 @@ } } } + }, + "Acompanhamento" : { + + }, + "Acompanhe seu pedido em tempo real" : { + }, "Add" : { "comment" : "Button in items list that will cause a new item to be added", @@ -471,9 +477,8 @@ } } }, - "Historico vazio" : { - "comment" : "A description displayed when a user has no orders in their history.", - "isCommentAutoGenerated" : true + "Histórico de Pedidos" : { + }, "Home" : { "comment" : "Tab bar item title for the Home tab", @@ -576,6 +581,9 @@ "Meus Endereços" : { "comment" : "A label displayed at the top of the view.", "isCommentAutoGenerated" : true + }, + "Meus Pedidos" : { + }, "Minhas Avaliações" : { "comment" : "A button that links to their reviews.", @@ -633,6 +641,9 @@ "Nenhum estabelecimento encontrado próximo à sua localização." : { "comment" : "A message displayed when no nearby stores are found.", "isCommentAutoGenerated" : true + }, + "Nenhum pedido encontrado." : { + }, "Notes" : { "comment" : "Item editor form label for the Notes field", @@ -691,9 +702,14 @@ "comment" : "A text label displaying the email address to which the one-time password (OTP) was sent. The text is truncated to fit within one line.", "isCommentAutoGenerated" : true }, - "Pedidos" : { - "comment" : "Navigation links for different sections of the user's profile.", - "isCommentAutoGenerated" : true + "Pedido #%@" : { + + }, + "Pedido %@" : { + + }, + "Pedido ID #%@" : { + }, "PediFoods" : { "comment" : "The name of the app.", @@ -860,6 +876,9 @@ "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", diff --git a/pedi-foods/Sources/PediFoods/Services/ApiConfig.swift b/pedi-foods/Sources/PediFoods/Services/ApiConfig.swift index 1e62dfa..1fc39db 100644 --- a/pedi-foods/Sources/PediFoods/Services/ApiConfig.swift +++ b/pedi-foods/Sources/PediFoods/Services/ApiConfig.swift @@ -10,8 +10,8 @@ enum ApiModule: Sendable { enum ApiConfig { static var baseURL: URL { - let raw = ProcessInfo.processInfo.environment["ATOMENTA_API_URL"] ?? "http://127.0.0.1:3001" - return URL(string: raw) ?? URL(string: "http://127.0.0.1:3001")! + let raw = ProcessInfo.processInfo.environment["ATOMENTA_API_URL"] ?? "https://atomenta.com.br" + return URL(string: raw) ?? URL(string: "https://atomenta.com.br")! } // Tokens provided by backend modules diff --git a/pedi-foods/Sources/PediFoods/Services/ApiOrderTrackingModels.swift b/pedi-foods/Sources/PediFoods/Services/ApiOrderTrackingModels.swift new file mode 100644 index 0000000..5f47ab3 --- /dev/null +++ b/pedi-foods/Sources/PediFoods/Services/ApiOrderTrackingModels.swift @@ -0,0 +1,287 @@ +import Foundation + +struct AppOrderSummary: Decodable, Identifiable { + let id: String + let shortId: String? + let total: Double? + let status: String? + let paymentStatus: String? + let paymentMethod: String? + let deliveryType: String? + let storeName: String? + let createdAt: String? + let updatedAt: String? + + enum CodingKeys: String, CodingKey { + case id + case shortId + case total + case status + case paymentStatus + case paymentMethod + case deliveryType + case storeName + 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) + 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) + } +} + +struct PublicOrderResult: Codable, Identifiable { + let id: String + let shortId: String? + let status: String? + let paymentStatus: String? + let paymentMethod: String? + let deliveryType: String? + let total: Double? + let storeName: String? + let createdAt: String? + let updatedAt: String? + let otp: String? + let customerOtp: String? + let confirmOtp: String? + let items: [PublicOrderItem] + let timeline: [PublicOrderTimelineEvent] + + enum CodingKeys: String, CodingKey { + case id + case shortId + case status + case paymentStatus + case paymentMethod + case deliveryType + case total + case storeName + case createdAt + case updatedAt + case otp + case customerOtp + case confirmOtp + case items + case timeline + } + + init( + id: String, + shortId: String? = nil, + status: String? = nil, + paymentStatus: String? = nil, + paymentMethod: String? = nil, + deliveryType: String? = nil, + total: Double? = nil, + storeName: String? = nil, + createdAt: String? = nil, + updatedAt: String? = nil, + otp: String? = nil, + customerOtp: String? = nil, + confirmOtp: String? = nil, + items: [PublicOrderItem] = [], + timeline: [PublicOrderTimelineEvent] = [] + ) { + self.id = id + self.shortId = shortId + self.status = status + self.paymentStatus = paymentStatus + self.paymentMethod = paymentMethod + self.deliveryType = deliveryType + self.total = total + self.storeName = storeName + self.createdAt = createdAt + self.updatedAt = updatedAt + self.otp = otp + self.customerOtp = customerOtp + self.confirmOtp = confirmOtp + 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) + 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) + items = (try? container.decode([PublicOrderItem].self, forKey: .items)) ?? [] + timeline = (try? container.decode([PublicOrderTimelineEvent].self, forKey: .timeline)) ?? [] + } + + var displayOtpCode: String? { + let values = [customerOtp, otp, confirmOtp] + for value in values { + let trimmed = (value ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.isEmpty == false { return trimmed } + } + return nil + } + + var isInDeliveryRoute: Bool { + let normalized = (status ?? "").uppercased() + if normalized.contains("OUT_FOR_DELIVERY") { return true } + if normalized.contains("EM_ROTA") { return true } + if normalized.contains("ON_ROUTE") { return true } + if normalized.contains("ROTA") { return true } + return false + } + + var isFinalStatus: Bool { + let normalized = (status ?? "").uppercased() + return normalized == "COMPLETED" || normalized == "CANCELED" || normalized == "REFUNDED" + } + + var isPaymentConfirmed: Bool { + let payment = (paymentStatus ?? "").uppercased() + let currentStatus = (status ?? "").uppercased() + if Self.looksConfirmed(payment) || Self.looksConfirmed(currentStatus) { + return true + } + + // Fallback: alguns ambientes atualizam apenas a timeline primeiro. + if timeline.contains(where: { event in + let statusValue = (event.status ?? "").uppercased() + let messageValue = (event.message ?? "").uppercased() + return Self.looksConfirmed(statusValue) || Self.looksConfirmed(messageValue) + }) { + return true + } + + return false + } + + private static func looksConfirmed(_ value: String) -> Bool { + if value.isEmpty { return false } + if value.contains("PENDING") || value.contains("AWAIT") { return false } + if value.contains("FAILED") || value.contains("ERROR") { return false } + if value.contains("CANCEL") || value.contains("REFUND") { return false } + + if value.contains("CONFIRM") { return true } + if value.contains("APPROV") { return true } + if value.contains("PAID") { return true } + if value.contains("RECEIV") { return true } + + return value == "SUCCESS" || value == "DONE" + } +} + +struct PublicOrderItem: Codable, Identifiable { + let id: String + let name: String? + let qty: Int? + let price: Double? + + enum CodingKeys: String, CodingKey { + case id + case name + case qty + case quantity + case price + } + + init(id: String = UUID().uuidString, name: String?, qty: Int?, price: Double?) { + self.id = id + self.name = name + self.qty = qty + self.price = price + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = (try? container.decode(String.self, forKey: .id)) ?? UUID().uuidString + name = try? container.decode(String.self, forKey: .name) + qty = ApiService.decodeFlexibleInt(from: container, keys: [.qty, .quantity]) + price = ApiService.decodeFlexibleDouble(from: container, keys: [.price]) + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(id, forKey: .id) + try container.encodeIfPresent(name, forKey: .name) + try container.encodeIfPresent(qty, forKey: .qty) + try container.encodeIfPresent(price, forKey: .price) + } +} + +struct PublicOrderTimelineEvent: Codable, Identifiable { + let id: String + let status: String? + let message: String? + let time: String? + + enum CodingKeys: String, CodingKey { + case id + case status + case message + case time + case createdAt + case updatedAt + } + + init(id: String = UUID().uuidString, status: String?, message: String?, time: String?) { + self.id = id + self.status = status + self.message = message + self.time = time + } + + init(from decoder: Decoder) throws { + 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) + message = try? container.decode(String.self, forKey: .message) + time = (try? container.decode(String.self, forKey: .time)) + ?? (try? container.decode(String.self, forKey: .createdAt)) + ?? (try? container.decode(String.self, forKey: .updatedAt)) + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(id, forKey: .id) + try container.encodeIfPresent(status, forKey: .status) + try container.encodeIfPresent(message, forKey: .message) + try container.encodeIfPresent(time, forKey: .time) + } +} + +struct OrderRealtimeUpdate: Decodable { + let id: String? + let shortId: String? + let storeId: String? + let userId: String? + let status: String? + let paymentStatus: String? + let updatedAt: String? +} + +extension CreateOrderResult { + func asPublicOrderResult() -> PublicOrderResult { + PublicOrderResult( + id: id ?? UUID().uuidString, + shortId: shortId, + status: status, + paymentStatus: paymentStatus, + paymentMethod: paymentMethod + ) + } +} diff --git a/pedi-foods/Sources/PediFoods/Services/ApiService.swift b/pedi-foods/Sources/PediFoods/Services/ApiService.swift index 449e931..988504c 100644 --- a/pedi-foods/Sources/PediFoods/Services/ApiService.swift +++ b/pedi-foods/Sources/PediFoods/Services/ApiService.swift @@ -247,6 +247,16 @@ final class ApiService { let req = ApiRequest(path: "/api/store/\(storeId)/delivery/validate-address", method: "POST", module: .store, requiresAuth: true, body: body) return try await sendEnvelope(req) } + + func listOrders() async throws -> ApiEnvelope<[AppOrderSummary]> { + let req = ApiRequest(path: "/api/app/orders", method: "GET", module: .app, requiresAuth: true) + return try await sendEnvelope(req) + } + + func publicOrder(orderId: String) async throws -> ApiEnvelope { + let req = ApiRequest(path: "/api/public/orders/\(orderId)", method: "GET", module: .none, requiresAuth: true) + return try await sendEnvelope(req) + } } extension ApiService { static func decodeFlexibleDouble(from container: KeyedDecodingContainer, keys: [K]) -> Double? { diff --git a/pedi-foods/Sources/PediFoods/Services/OrderRealtimeTracker.swift b/pedi-foods/Sources/PediFoods/Services/OrderRealtimeTracker.swift new file mode 100644 index 0000000..a1cd507 --- /dev/null +++ b/pedi-foods/Sources/PediFoods/Services/OrderRealtimeTracker.swift @@ -0,0 +1,215 @@ +import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif + +@MainActor +final class OrderRealtimeTracker { + private var pollingTask: Task? = nil + private var socketClient: OrderSocketClient? = nil + private var activeOrderId: String? = nil + + var onOrderUpdated: ((PublicOrderResult) -> Void)? + + func start(orderId: String, jwt: String?) { + stop() + activeOrderId = orderId + + pollingTask = Task { @MainActor [weak self] in + guard let self else { return } + await self.runPollingLoop(orderId: orderId) + } + + guard let jwt, jwt.isEmpty == false else { return } + let socket = OrderSocketClient() + socket.onOrderUpdate = { [weak self] update in + guard let self else { return } + guard update.id == orderId else { return } + Task { @MainActor [weak self] in + guard let self else { return } + await self.fetchLatest(orderId: orderId) + } + } + socket.connect(jwt: jwt) + socketClient = socket + } + + func stop() { + pollingTask?.cancel() + pollingTask = nil + socketClient?.disconnect() + socketClient = nil + activeOrderId = nil + } + + private func runPollingLoop(orderId: String) async { + var elapsedSeconds = 0 + + while Task.isCancelled == false { + if activeOrderId != orderId { return } + + let fetched = await fetchLatest(orderId: orderId) + if fetched?.isFinalStatus == true { + return + } + + let delay = pollingDelay(for: elapsedSeconds) + elapsedSeconds += delay + + do { + try await Task.sleep(nanoseconds: UInt64(delay) * 1_000_000_000) + } catch { + return + } + } + } + + private func pollingDelay(for elapsedSeconds: Int) -> Int { + if elapsedSeconds < 60 { return 3 } + if elapsedSeconds < 180 { return 5 } + return 10 + } + + @discardableResult + private func fetchLatest(orderId: String) async -> PublicOrderResult? { + do { + let response = try await ApiService().publicOrder(orderId: orderId) + guard response.error == false, let order = response.result else { return nil } + SessionStateStore.saveTrackedOrder(order) + onOrderUpdated?(order) + return order + } catch { + return nil + } + } +} + +final class OrderSocketClient: @unchecked Sendable { + var onOrderUpdate: ((OrderRealtimeUpdate) -> Void)? + +#if os(iOS) || os(macOS) + private var task: URLSessionWebSocketTask? = nil + private let session = URLSession(configuration: .default) + private var isConnected = false + private var pendingJWT: String? = nil +#endif + + func connect(jwt: String) { +#if os(iOS) || os(macOS) + disconnect() + guard let url = makeSocketURL() else { return } + + let wsTask = session.webSocketTask(with: url) + wsTask.resume() + task = wsTask + pendingJWT = jwt + receiveLoop() +#else + _ = jwt +#endif + } + + func disconnect() { +#if os(iOS) || os(macOS) + isConnected = false + pendingJWT = nil + task?.cancel(with: .goingAway, reason: nil) + task = nil +#endif + } + +#if os(iOS) || os(macOS) + private func receiveLoop() { + guard let task else { return } + task.receive { [weak self] result in + guard let self else { return } + switch result { + case .failure: + self.disconnect() + case .success(let message): + self.handleMessage(message) + self.receiveLoop() + } + } + } + + private func handleMessage(_ message: URLSessionWebSocketTask.Message) { + let text: String + switch message { + case .string(let value): + text = value + case .data(let data): + text = String(data: data, encoding: .utf8) ?? "" + @unknown default: + return + } + + guard text.isEmpty == false else { return } + + if text == "2" { + task?.send(.string("3")) { _ in } + return + } + + if text.hasPrefix("0"), let jwt = pendingJWT { + let authPacket = "40{\"token\":\"Bearer \(jwt)\"}" + task?.send(.string(authPacket)) { _ in } + pendingJWT = nil + return + } + + if text.hasPrefix("40") { + isConnected = true + return + } + + guard text.hasPrefix("42") else { return } + let eventPayload = String(text.dropFirst(2)) + guard let data = eventPayload.data(using: .utf8) else { return } + + if let rawArray = try? JSONSerialization.jsonObject(with: data) as? [Any], + rawArray.count >= 2, + let eventName = rawArray[0] as? String, + eventName == "order_update" { + let payloadAny = rawArray[1] + guard JSONSerialization.isValidJSONObject(payloadAny), + let payloadData = try? JSONSerialization.data(withJSONObject: payloadAny), + let update = try? JSONDecoder().decode(OrderRealtimeUpdate.self, from: payloadData) else { + return + } + onOrderUpdate?(update) + return + } + + // Compat: alguns servidores podem encapsular o evento como objeto. + if let rawObject = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let eventName = (rawObject["event"] as? String)?.lowercased(), + eventName == "order_update", + let payloadAny = rawObject["data"], + JSONSerialization.isValidJSONObject(payloadAny), + let payloadData = try? JSONSerialization.data(withJSONObject: payloadAny), + let update = try? JSONDecoder().decode(OrderRealtimeUpdate.self, from: payloadData) { + onOrderUpdate?(update) + } + } + + private func makeSocketURL() -> URL? { + guard var components = URLComponents(url: ApiConfig.baseURL, resolvingAgainstBaseURL: false) else { + return nil + } + + if components.scheme == "https" { + components.scheme = "wss" + } else { + components.scheme = "ws" + } + components.path = "/socket.io/" + components.queryItems = [ + URLQueryItem(name: "EIO", value: "4"), + URLQueryItem(name: "transport", value: "websocket") + ] + + return components.url + } +#endif +} diff --git a/pedi-foods/Sources/PediFoods/Services/SessionStateStore.swift b/pedi-foods/Sources/PediFoods/Services/SessionStateStore.swift index 0492958..419f6a5 100644 --- a/pedi-foods/Sources/PediFoods/Services/SessionStateStore.swift +++ b/pedi-foods/Sources/PediFoods/Services/SessionStateStore.swift @@ -33,11 +33,16 @@ private struct PersistedCartState: Codable { let total: Double } +private struct PersistedTrackedOrdersState: Codable { + let orders: [PublicOrderResult] +} + enum SessionStateStore { private static let legacyAddressKey = "session.address.state.v1" private static let addressKeyPrefix = "session.address.state.v2." private static let activeUserKey = "session.active.user.v1" private static let cartKeyPrefix = "session.cart.state.v1." + private static let trackedOrdersKeyPrefix = "session.orders.tracking.v1." static func makeUserKey(profileId: String?, email: String?) -> String? { let id = (profileId ?? "") @@ -240,4 +245,44 @@ enum SessionStateStore { static func clearCart() { UserDefaults.standard.removeObject(forKey: cartStorageKey(for: nil)) } + + private static func trackedOrdersStorageKey(for userKey: String?) -> String { + let key = (userKey ?? loadActiveUserKey() ?? "anonymous") + .trimmingCharacters(in: .whitespacesAndNewlines) + let safe = key.replacingOccurrences(of: " ", with: "_") + return trackedOrdersKeyPrefix + safe + } + + static func loadTrackedOrders() -> [PublicOrderResult] { + let defaults = UserDefaults.standard + let key = trackedOrdersStorageKey(for: nil) + guard let data = defaults.data(forKey: key), + let decoded = try? JSONDecoder().decode(PersistedTrackedOrdersState.self, from: data) else { + return [] + } + return decoded.orders + } + + static func loadTrackedOrder(orderId: String) -> PublicOrderResult? { + loadTrackedOrders().first(where: { $0.id == orderId }) + } + + static func saveTrackedOrder(_ order: PublicOrderResult) { + var orders = loadTrackedOrders() + if let index = orders.firstIndex(where: { $0.id == order.id }) { + orders[index] = order + } else { + orders.insert(order, at: 0) + } + if orders.count > 60 { + orders = Array(orders.prefix(60)) + } + let payload = PersistedTrackedOrdersState(orders: orders) + guard let data = try? JSONEncoder().encode(payload) else { return } + UserDefaults.standard.set(data, forKey: trackedOrdersStorageKey(for: nil)) + } + + static func clearTrackedOrders() { + UserDefaults.standard.removeObject(forKey: trackedOrdersStorageKey(for: nil)) + } } diff --git a/pedi-foods/Sources/PediFoods/Views/Auth/OtpView.swift b/pedi-foods/Sources/PediFoods/Views/Auth/OtpView.swift index 0b97e7d..b2d8f48 100644 --- a/pedi-foods/Sources/PediFoods/Views/Auth/OtpView.swift +++ b/pedi-foods/Sources/PediFoods/Views/Auth/OtpView.swift @@ -187,8 +187,8 @@ struct OtpView: View { SnackbarCenter.shared.show(title: "Login realizado com sucesso.", style: .success, icon: "checkmark.seal.fill", duration: 2.5) appState.session.isAuthenticated = true appState.session.jwt = response.result?.token - hydrateUserState(from: response.result?.customer) - routeAfterLogin() + let hasServerAddress = hydrateUserState(from: response.result?.customer) + routeAfterLogin(hasServerAddress: hasServerAddress) } } catch { await MainActor.run { @@ -201,16 +201,8 @@ struct OtpView: View { } } - private func routeAfterLogin() { - if let cached = LocationService.shared.cachedLocation() { - appState.address.latitude = cached.0 - appState.address.longitude = cached.1 - selectedTab = .home - root = .main - return - } - - if hasConfiguredAddress() { + private func routeAfterLogin(hasServerAddress: Bool) { + if hasServerAddress || hasConfiguredAddress() { selectedTab = .home root = .main return @@ -221,7 +213,7 @@ struct OtpView: View { root = .main } - private func hydrateUserState(from customer: CustomerProfile?) { + private func hydrateUserState(from customer: CustomerProfile?) -> Bool { if let customer { appState.profile.id = customer.id appState.profile.name = customer.name @@ -236,21 +228,24 @@ struct OtpView: View { appState.cart = CartState() } - if let preferred = customer.addressBook?.first { + let addresses = customer.addressBook ?? [] + if let preferred = addresses.first { appState.address.selectedId = preferred.id let label = (preferred.label ?? "").trimmingCharacters(in: .whitespacesAndNewlines) appState.address.display = label.isEmpty ? "Defina seu endereco" : label if let lat = preferred.latLong?.first, let lng = preferred.latLong?.dropFirst().first { appState.address.latitude = lat appState.address.longitude = lng + } else { + appState.address.latitude = nil + appState.address.longitude = nil } SessionStateStore.saveAddress(appState.address) - } else if let cached = SessionStateStore.loadAddress() { - appState.address = cached } else { appState.address = AddressState() + SessionStateStore.clearAddress() } - return + return addresses.isEmpty == false } appState.profile.email = email @@ -262,11 +257,8 @@ struct OtpView: View { } else { appState.cart = CartState() } - if let cached = SessionStateStore.loadAddress() { - appState.address = cached - } else { - appState.address = AddressState() - } + appState.address = AddressState() + return false } private func hasConfiguredAddress() -> Bool { @@ -274,10 +266,6 @@ struct OtpView: View { return true } - if appState.address.latitude != nil, appState.address.longitude != nil { - return true - } - let normalized = appState.address.display .trimmingCharacters(in: .whitespacesAndNewlines) .lowercased() diff --git a/pedi-foods/Sources/PediFoods/Views/Main/AddressComponents.swift b/pedi-foods/Sources/PediFoods/Views/Main/AddressComponents.swift index c0f2439..07766a4 100644 --- a/pedi-foods/Sources/PediFoods/Views/Main/AddressComponents.swift +++ b/pedi-foods/Sources/PediFoods/Views/Main/AddressComponents.swift @@ -201,16 +201,3 @@ struct AddressListItem: Identifiable { let icon: String let isPrimary: Bool } - -struct OrdersView: View { - var body: some View { - VStack(spacing: 16) { - Text("Pedidos") - .font(AppTypography.heading1) - Text("Historico vazio") - .foregroundStyle(AppColors.secondary) - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - .background(AppColors.backgroundLight) - } -} diff --git a/pedi-foods/Sources/PediFoods/Views/Main/CheckoutView+Logic.swift b/pedi-foods/Sources/PediFoods/Views/Main/CheckoutView+Logic.swift index a905a34..2564304 100644 --- a/pedi-foods/Sources/PediFoods/Views/Main/CheckoutView+Logic.swift +++ b/pedi-foods/Sources/PediFoods/Views/Main/CheckoutView+Logic.swift @@ -211,21 +211,6 @@ extension CheckoutView { func handleConfirmPaymentTap() async { guard canConfirmPayment else { return } - if useInAppPayment == false { - SnackbarCenter.shared.show(title: "Pagamento presencial selecionado.", style: .info, icon: "creditcard.fill", duration: 2.0) - return - } - - if paymentMethod == .creditCard { - openCardPayment = true - return - } - - guard paymentMethod == .pix else { - SnackbarCenter.shared.show(title: "Método de pagamento não suportado no app.", style: .warning, icon: "exclamationmark.triangle.fill", duration: 2.0) - return - } - guard let storeId = appState.cart.storeId, storeId.isEmpty == false else { SnackbarCenter.shared.show(title: "Loja do pedido não encontrada.", style: .error, icon: "xmark.octagon.fill", duration: 2.0) return @@ -233,7 +218,13 @@ extension CheckoutView { await refreshSelectedCustomerAddress() - let payloadBuildResult = buildCreateOrderPayload(paymentMethod: .pix) + let effectivePaymentMethod = paymentMethod + if useInAppPayment && availableInAppPaymentMethods.contains(effectivePaymentMethod) == false { + SnackbarCenter.shared.show(title: "Método de pagamento não suportado no app.", style: .warning, icon: "exclamationmark.triangle.fill", duration: 2.0) + return + } + + let payloadBuildResult = buildCreateOrderPayload(paymentMethod: effectivePaymentMethod) guard case .success(let payload) = payloadBuildResult else { let message: String if case .failure(let reason) = payloadBuildResult { @@ -252,7 +243,7 @@ extension CheckoutView { let response = try await ApiService().createOrder(storeId: storeId, payload: payload) if response.error { SnackbarCenter.shared.show( - title: response.message ?? "Não foi possível gerar o pagamento PIX.", + title: response.message ?? "Não foi possível criar o pedido.", style: .error, icon: "xmark.octagon.fill", duration: 3.0 @@ -265,23 +256,52 @@ extension CheckoutView { return } - let pixPayload = result.payment?.pix ?? result.paymentPayload - guard let copyPaste = pixPayload?.copyPaste, copyPaste.isEmpty == false else { + let orderSnapshot = result.asPublicOrderResult() + SessionStateStore.saveTrackedOrder(orderSnapshot) + + let orderId = result.id ?? UUID().uuidString + if useInAppPayment == false { + orderTrackingContext = OrderTrackingContext(orderId: orderId, shortId: result.shortId) + return + } + + if effectivePaymentMethod == .creditCard { + cardPaymentContext = CardPaymentContext( + orderId: orderId, + shortId: result.shortId, + total: totalValue + ) + return + } + + let pixFromPayment = result.payment?.pix + let pixFromPayload = result.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 + + guard let copyPaste, copyPaste.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false else { SnackbarCenter.shared.show(title: "Código PIX não retornado pela API.", style: .error, icon: "xmark.octagon.fill", duration: 3.0) return } - let orderId = result.id ?? UUID().uuidString pixPaymentContext = PixPaymentContext( id: orderId, orderId: orderId, shortId: result.shortId, copyPaste: copyPaste, - qrCodeImageBase64: pixPayload?.qrCodeImage, - expirationDate: pixPayload?.expirationDate + qrCodeImageBase64: qrCodeImage, + expirationDate: expirationDate ) } catch { - SnackbarCenter.shared.show(title: "Não foi possível gerar o pagamento PIX.", style: .error, icon: "xmark.octagon.fill", duration: 3.0) + SnackbarCenter.shared.show(title: "Não foi possível criar o pedido.", style: .error, icon: "xmark.octagon.fill", duration: 3.0) } } diff --git a/pedi-foods/Sources/PediFoods/Views/Main/CheckoutView.swift b/pedi-foods/Sources/PediFoods/Views/Main/CheckoutView.swift index cdd6afe..7295236 100644 --- a/pedi-foods/Sources/PediFoods/Views/Main/CheckoutView.swift +++ b/pedi-foods/Sources/PediFoods/Views/Main/CheckoutView.swift @@ -24,7 +24,8 @@ struct CheckoutView: View { @State var lastAcceptedAddressState: AddressState? = nil @State var isSubmittingOrder = false @State var pixPaymentContext: PixPaymentContext? = nil - @State var openCardPayment = false + @State var cardPaymentContext: CardPaymentContext? = nil + @State var orderTrackingContext: OrderTrackingContext? = nil var paymentConfig: StorePaymentMethodsInfo? { storeInfo?.paymentMethods } @@ -151,10 +152,17 @@ struct CheckoutView: View { } } .navigationDestination(item: $pixPaymentContext) { context in - PaymentPixView(context: context) + PaymentPixView(context: context) { + orderTrackingContext = OrderTrackingContext(orderId: context.orderId, shortId: context.shortId) + } } - .navigationDestination(isPresented: $openCardPayment) { - PaymentCardView(total: totalValue) + .navigationDestination(item: $cardPaymentContext) { context in + PaymentCardView(context: context) { + orderTrackingContext = OrderTrackingContext(orderId: context.orderId, shortId: context.shortId) + } + } + .navigationDestination(item: $orderTrackingContext) { context in + OrderTrackingView(orderId: context.orderId, initialShortId: context.shortId) } } @@ -529,9 +537,26 @@ struct PixPaymentContext: Identifiable, Hashable { let expirationDate: String? } +struct OrderTrackingContext: Identifiable, Hashable { + var id: String { orderId } + let orderId: String + let shortId: String? +} + +struct CardPaymentContext: Identifiable, Hashable { + var id: String { orderId } + let orderId: String + let shortId: String? + let total: Double +} + struct PaymentPixView: View { let context: PixPaymentContext + var onOpenTracking: (() -> Void)? = nil @Environment(\.dismiss) var dismiss + @State var tracker = OrderRealtimeTracker() + @State var latestOrder: PublicOrderResult? = nil + @State var hasOpenedTracking = false private var qrImageSource: String? { guard let raw = context.qrCodeImageBase64?.trimmingCharacters(in: .whitespacesAndNewlines), @@ -601,7 +626,11 @@ struct PaymentPixView: View { } Button("Já realizei o pagamento") { - dismiss() + 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) @@ -612,6 +641,25 @@ struct PaymentPixView: View { .background(AppColors.backgroundLight) .navigationTitle("Pagamento via PIX") .navigationBarTitleDisplayMode(.inline) + .task { + tracker.onOrderUpdated = { updated in + latestOrder = updated + if updated.isPaymentConfirmed { + openTrackingOnce() + } + } + tracker.start(orderId: context.orderId, jwt: DefaultTokenStore().jwt) + } + .onDisappear { + tracker.stop() + } + } + + private func openTrackingOnce() { + guard hasOpenedTracking == false else { return } + hasOpenedTracking = true + onOpenTracking?() + dismiss() } private func copyToClipboard(_ value: String) { @@ -625,12 +673,16 @@ struct PaymentPixView: View { } struct PaymentCardView: View { - let total: Double + let context: CardPaymentContext + var onOpenTracking: (() -> Void)? = nil @Environment(\.dismiss) var dismiss @State var cardHolderName = "" @State var cardNumber = "" @State var expiry = "" @State var cvv = "" + @State var tracker = OrderRealtimeTracker() + @State var latestOrder: PublicOrderResult? = nil + @State var hasOpenedTracking = false var body: some View { ScrollView(showsIndicators: false) { @@ -643,7 +695,7 @@ struct PaymentCardView: View { Text("Total do Pedido") .font(AppTypography.caption) .foregroundStyle(AppColors.textMuted) - Text(formatCurrency(total)) + Text(formatCurrency(context.total)) .font(AppTypography.heading2) .foregroundStyle(AppColors.textPrimary) } @@ -670,11 +722,19 @@ struct PaymentCardView: View { .clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous)) PrimaryButton(title: "Salvar e Pagar") { - SnackbarCenter.shared.show(title: "Fluxo de cartão em construção.", style: .info, icon: "creditcard.fill", duration: 2.0) + if latestOrder?.isPaymentConfirmed == true { + openTrackingOnce() + } else { + SnackbarCenter.shared.show(title: "Pagamento ainda não confirmado.", style: .info, icon: "clock.fill", duration: 2.0) + } } Button("Apenas Pagar") { - dismiss() + if latestOrder?.isPaymentConfirmed == true { + openTrackingOnce() + } else { + SnackbarCenter.shared.show(title: "Aguardando confirmação do pagamento.", style: .info, icon: "clock.fill", duration: 2.0) + } } .font(AppTypography.heading3) .foregroundStyle(AppColors.textMuted) @@ -688,6 +748,25 @@ struct PaymentCardView: View { .background(AppColors.backgroundLight) .navigationTitle("Pagamento") .navigationBarTitleDisplayMode(.inline) + .task { + tracker.onOrderUpdated = { updated in + latestOrder = updated + if updated.isPaymentConfirmed { + openTrackingOnce() + } + } + tracker.start(orderId: context.orderId, jwt: DefaultTokenStore().jwt) + } + .onDisappear { + tracker.stop() + } + } + + private func openTrackingOnce() { + 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 155d45f..777a723 100644 --- a/pedi-foods/Sources/PediFoods/Views/Main/HomeView+Data.swift +++ b/pedi-foods/Sources/PediFoods/Views/Main/HomeView+Data.swift @@ -78,6 +78,10 @@ extension HomeView { @MainActor func resolveCoordinates(forceRefresh: Bool) async -> (Double, Double)? { + guard hasConfiguredAddress() else { + return nil + } + if !forceRefresh { if let lat = appState.address.latitude, let lng = appState.address.longitude { return (lat, lng) @@ -87,9 +91,6 @@ extension HomeView { appState.address.longitude = cached.1 return cached } - if hasConfiguredAddress() == false { - return nil - } } let coordinate = await LocationService.shared.requestLocationAsync(timeoutSeconds: 3) if let coordinate { @@ -103,9 +104,6 @@ extension HomeView { if appState.address.selectedId != nil { return true } - if appState.address.latitude != nil, appState.address.longitude != nil { - return true - } let normalized = appState.address.display .trimmingCharacters(in: .whitespacesAndNewlines) diff --git a/pedi-foods/Sources/PediFoods/Views/Main/OrderTrackingView.swift b/pedi-foods/Sources/PediFoods/Views/Main/OrderTrackingView.swift new file mode 100644 index 0000000..c73cb42 --- /dev/null +++ b/pedi-foods/Sources/PediFoods/Views/Main/OrderTrackingView.swift @@ -0,0 +1,263 @@ +import SwiftUI + +struct OrderTrackingView: View { + let orderId: String + let initialShortId: String? + + @State var isLoading = true + @State var errorMessage: String? = nil + @State var order: PublicOrderResult? = nil + @State var tracker = OrderRealtimeTracker() + + 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 + } + .padding(.horizontal, 20) + .padding(.top, 14) + .padding(.bottom, UIDevice.bottomNotch + 24) + } + .background(AppColors.backgroundLight) + .navigationTitle("Pedido \(displayOrderTitle)") + .navigationBarTitleDisplayMode(.inline) + .task { + await loadInitialOrder() + tracker.onOrderUpdated = { updated in + order = updated + isLoading = false + errorMessage = nil + } + tracker.start(orderId: orderId, jwt: DefaultTokenStore().jwt) + } + .onDisappear { + tracker.stop() + } + } + + 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 headerCard: some View { + VStack(alignment: .leading, spacing: 6) { + Text(statusTitle) + .font(AppTypography.heading1) + .foregroundStyle(AppColors.textPrimary) + + Text("Pedido ID #\(order?.shortId ?? initialShortId ?? orderId)") + .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 + ) + ] + }() + + return VStack(alignment: .leading, spacing: 12) { + Text("Acompanhamento") + .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) + } + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(16) + .background(AppColors.surface) + .clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous)) + } + + private func timelineRow(event: PublicOrderTimelineEvent, isLast: Bool) -> some View { + HStack(alignment: .top, spacing: 12) { + VStack(spacing: 0) { + Circle() + .fill(AppColors.tertiary) + .frame(width: 20, height: 20) + .overlay( + Image(systemName: "checkmark") + .font(.system(size: 11, weight: .bold)) + .foregroundStyle(.white) + ) + + if isLast == false { + Rectangle() + .fill(AppColors.tertiary.opacity(0.45)) + .frame(width: 2, height: 30) + } + } + + 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) + } + + 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) + } + .frame(maxWidth: .infinity) + .padding(16) + .background(AppColors.surface) + .clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous)) + } + + private var statusTitle: String { + humanReadableStatus(order?.status ?? "Aguardando") + } + + private var statusPill: String { + let value = order?.paymentStatus ?? order?.status ?? "PENDING" + return humanReadableStatus(value) + } + + private func humanReadableStatus(_ raw: String) -> String { + let normalized = raw.uppercased() + if normalized.contains("PAYMENT_PENDING") || normalized == "PENDING" { + return "Aguardando pagamento" + } + if normalized.contains("CONFIRMED") { + return "Pagamento confirmado" + } + if normalized.contains("PREPAR") { + return "Em preparo" + } + if normalized.contains("OUT_FOR_DELIVERY") || normalized.contains("EM_ROTA") || normalized.contains("ROTA") { + return "Em rota de entrega" + } + if normalized.contains("COMPLETED") || normalized.contains("DELIVERED") { + return "Pedido entregue" + } + if normalized.contains("CANCEL") { + return "Pedido cancelado" + } + return raw.capitalized + } + + private func formatTime(_ isoValue: String?) -> String? { + guard let isoValue, isoValue.isEmpty == false else { return nil } + + let iso = ISO8601DateFormatter() + iso.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + + var date: Date? = iso.date(from: isoValue) + if date == nil { + iso.formatOptions = [.withInternetDateTime] + date = iso.date(from: isoValue) + } + + 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" + return formatter.string(from: date) + } + + @MainActor + private func loadInitialOrder() async { + if let cached = SessionStateStore.loadTrackedOrder(orderId: orderId) { + order = cached + isLoading = false + } + + do { + let response = try await ApiService().publicOrder(orderId: orderId) + if response.error { + errorMessage = response.message ?? "Não foi possível carregar o pedido." + } else if let result = response.result { + order = result + SessionStateStore.saveTrackedOrder(result) + errorMessage = nil + } + } catch { + if order == nil { + errorMessage = "Não foi possível carregar o pedido." + } + } + + isLoading = false + } +} diff --git a/pedi-foods/Sources/PediFoods/Views/Main/OrdersView.swift b/pedi-foods/Sources/PediFoods/Views/Main/OrdersView.swift new file mode 100644 index 0000000..ab7bdd7 --- /dev/null +++ b/pedi-foods/Sources/PediFoods/Views/Main/OrdersView.swift @@ -0,0 +1,205 @@ +import SwiftUI + +struct OrdersView: View { + @State var isLoading = false + @State var errorMessage: String? = nil + @State var orders: [AppOrderSummary] = [] + + var body: some View { + ScrollView(showsIndicators: false) { + VStack(alignment: .leading, spacing: 14) { + Text("Histórico de Pedidos") + .font(AppTypography.heading1) + .foregroundStyle(AppColors.textPrimary) + .padding(.top, 6) + + if isLoading { + ProgressView() + .frame(maxWidth: .infinity) + .padding(.top, 24) + } else if let errorMessage, errorMessage.isEmpty == false { + Text(errorMessage) + .font(AppTypography.body) + .foregroundStyle(Color.red) + .multilineTextAlignment(.center) + .frame(maxWidth: .infinity) + .padding(.top, 24) + } else if orders.isEmpty { + Text("Nenhum pedido encontrado.") + .font(AppTypography.body) + .foregroundStyle(AppColors.textMuted) + .padding(.top, 24) + } else { + ForEach(orders) { order in + NavigationLink { + OrderTrackingView(orderId: order.id, initialShortId: order.shortId) + } label: { + orderRow(order) + } + .buttonStyle(.plain) + } + } + } + .padding(.horizontal, 20) + .padding(.bottom, UIDevice.bottomNotch + 18) + } + .background(AppColors.backgroundLight) + .navigationTitle("Meus Pedidos") + .navigationBarTitleDisplayMode(.inline) + .task { + await loadOrders() + } + } + + private func orderRow(_ order: AppOrderSummary) -> some View { + HStack(spacing: 12) { + RoundedRectangle(cornerRadius: 10, style: .continuous) + .fill(AppColors.brandSoft) + .frame(width: 44, height: 44) + .overlay( + Image(systemName: "bag.fill") + .foregroundStyle(AppColors.primary) + ) + + VStack(alignment: .leading, spacing: 4) { + Text("Pedido #\(order.shortId ?? order.id)") + .font(AppTypography.heading3) + .foregroundStyle(AppColors.textPrimary) + + Text(humanReadableStatus(order.status ?? order.paymentStatus ?? "Pendente")) + .font(AppTypography.caption) + .foregroundStyle(AppColors.textMuted) + } + + Spacer() + + VStack(alignment: .trailing, spacing: 4) { + Text(formatCurrency(order.total ?? 0)) + .font(AppTypography.heading3) + .foregroundStyle(AppColors.textPrimary) + if let createdAt = formatDate(order.createdAt) { + Text(createdAt) + .font(AppTypography.caption) + .foregroundStyle(AppColors.textMuted) + } + } + } + .padding(14) + .background(AppColors.surface) + .clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous)) + } + + private func humanReadableStatus(_ raw: String) -> String { + 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("COMPLETED") || normalized.contains("DELIVERED") { return "Entregue" } + if normalized.contains("CANCEL") { return "Cancelado" } + return raw.capitalized + } + + private func formatCurrency(_ value: Double) -> String { + String(format: "R$ %.2f", value).replacingOccurrences(of: ".", with: ",") + } + + private func formatDate(_ isoValue: String?) -> String? { + guard let isoValue, isoValue.isEmpty == false else { return nil } + let iso = ISO8601DateFormatter() + iso.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + var date = iso.date(from: isoValue) + if date == nil { + iso.formatOptions = [.withInternetDateTime] + date = iso.date(from: isoValue) + } + guard let date else { return nil } + + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "pt_BR") + formatter.dateFormat = "dd/MM HH:mm" + return formatter.string(from: date) + } + + @MainActor + private func loadOrders() async { + isLoading = true + errorMessage = nil + + let cachedTracked = SessionStateStore.loadTrackedOrders() + if cachedTracked.isEmpty == false { + let mapped = cachedTracked.map { + AppOrderSummary.fromTracked($0) + } + orders = mergeOrders(apiOrders: orders, trackedOrders: mapped) + } + + do { + let response = try await ApiService().listOrders() + if response.error { + errorMessage = response.message ?? "Não foi possível carregar os pedidos." + } else { + let remote = response.result ?? [] + orders = mergeOrders(apiOrders: remote, trackedOrders: orders) + } + } catch { + if orders.isEmpty { + errorMessage = "Não foi possível carregar os pedidos." + } + } + + isLoading = false + } + + 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 } + + return map.values.sorted { lhs, rhs in + let left = lhs.updatedAt ?? lhs.createdAt ?? "" + let right = rhs.updatedAt ?? rhs.createdAt ?? "" + return left > right + } + } +} + +extension AppOrderSummary { + static func fromTracked(_ tracked: PublicOrderResult) -> AppOrderSummary { + AppOrderSummary( + id: tracked.id, + shortId: tracked.shortId, + total: tracked.total, + status: tracked.status, + paymentStatus: tracked.paymentStatus, + paymentMethod: tracked.paymentMethod, + deliveryType: tracked.deliveryType, + storeName: tracked.storeName, + createdAt: tracked.createdAt, + updatedAt: tracked.updatedAt + ) + } + + init( + id: String, + shortId: String?, + total: Double?, + status: String?, + paymentStatus: String?, + paymentMethod: String?, + deliveryType: String?, + storeName: String?, + createdAt: String?, + updatedAt: String? + ) { + self.id = id + self.shortId = shortId + self.total = total + self.status = status + self.paymentStatus = paymentStatus + self.paymentMethod = paymentMethod + self.deliveryType = deliveryType + self.storeName = storeName + self.createdAt = createdAt + self.updatedAt = updatedAt + } +} diff --git a/pedi-foods/Sources/PediFoods/Views/Main/ProfileView.swift b/pedi-foods/Sources/PediFoods/Views/Main/ProfileView.swift index 00c0d04..ad11989 100644 --- a/pedi-foods/Sources/PediFoods/Views/Main/ProfileView.swift +++ b/pedi-foods/Sources/PediFoods/Views/Main/ProfileView.swift @@ -192,6 +192,7 @@ struct ProfileView: View { private func logout() { tokenStore.clear() SessionStateStore.clearActiveUser() + SessionStateStore.clearTrackedOrders() AppContentCache.shared.invalidate() AppImageCache.shared.invalidateAll() appState = AppState()