Fix login

This commit is contained in:
Daniel Arantes Loverde
2026-02-21 18:51:39 -03:00
parent 0f5ad4c268
commit 3a3dc7217b
17 changed files with 1296 additions and 91 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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()

View File

@@ -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",

View File

@@ -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

View File

@@ -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
)
}
}

View File

@@ -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<PublicOrderResult> {
let req = ApiRequest(path: "/api/public/orders/\(orderId)", method: "GET", module: .none, requiresAuth: true)
return try await sendEnvelope(req)
}
}
extension ApiService {
static func decodeFlexibleDouble<K: CodingKey>(from container: KeyedDecodingContainer<K>, keys: [K]) -> Double? {

View File

@@ -0,0 +1,215 @@
import Foundation
#if canImport(FoundationNetworking)
import FoundationNetworking
#endif
@MainActor
final class OrderRealtimeTracker {
private var pollingTask: Task<Void, Never>? = 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
}

View File

@@ -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))
}
}

View File

@@ -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()
}
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()

View File

@@ -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)
}
}

View File

@@ -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)
}
}

View File

@@ -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<String>) -> some View {

View File

@@ -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)

View File

@@ -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
}
}

View File

@@ -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
}
}

View File

@@ -192,6 +192,7 @@ struct ProfileView: View {
private func logout() {
tokenStore.clear()
SessionStateStore.clearActiveUser()
SessionStateStore.clearTrackedOrders()
AppContentCache.shared.invalidate()
AppImageCache.shared.invalidateAll()
appState = AppState()