Fix login
This commit is contained in:
45
pedi-foods/.gitea/workflows/ci.yml
Normal file
45
pedi-foods/.gitea/workflows/ci.yml
Normal 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
|
||||
42
pedi-foods/.gitea/workflows/pr-validation.yml
Normal file
42
pedi-foods/.gitea/workflows/pr-validation.yml
Normal 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
|
||||
@@ -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()
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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? {
|
||||
|
||||
215
pedi-foods/Sources/PediFoods/Services/OrderRealtimeTracker.swift
Normal file
215
pedi-foods/Sources/PediFoods/Services/OrderRealtimeTracker.swift
Normal 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
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
263
pedi-foods/Sources/PediFoods/Views/Main/OrderTrackingView.swift
Normal file
263
pedi-foods/Sources/PediFoods/Views/Main/OrderTrackingView.swift
Normal 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
|
||||
}
|
||||
}
|
||||
205
pedi-foods/Sources/PediFoods/Views/Main/OrdersView.swift
Normal file
205
pedi-foods/Sources/PediFoods/Views/Main/OrdersView.swift
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -192,6 +192,7 @@ struct ProfileView: View {
|
||||
private func logout() {
|
||||
tokenStore.clear()
|
||||
SessionStateStore.clearActiveUser()
|
||||
SessionStateStore.clearTrackedOrders()
|
||||
AppContentCache.shared.invalidate()
|
||||
AppImageCache.shared.invalidateAll()
|
||||
appState = AppState()
|
||||
|
||||
Reference in New Issue
Block a user