From 4fc6a666b289e902baf81517cf88f01cc587f27a Mon Sep 17 00:00:00 2001 From: Daniel Arantes Loverde Date: Fri, 10 Jul 2026 10:23:59 -0300 Subject: [PATCH 1/5] [cart-checkout] Fix delivery fee and address sync using stale cached profile Cart's delivery fee and Checkout's address validation both fetched the customer profile without forcing a cache refresh (2h TTL), then let that possibly-stale address book unconditionally overwrite the just-picked appState.address coordinates before building the fee/validation payload. Result: changing the address on Home didn't reliably move the cart's delivery fee, and Checkout's 'Alterar' could silently revert to the old address when the stale coordinates made the backend report it as not served. Force-refresh the profile fetch and only use it to fill genuine gaps in appState.address, never to override a live user selection. --- Sources/PediFoods/Views/Main/CartView.swift | 26 +++++++++++++------ .../Views/Main/CheckoutView+Logic.swift | 23 +++++++++++----- 2 files changed, 35 insertions(+), 14 deletions(-) diff --git a/Sources/PediFoods/Views/Main/CartView.swift b/Sources/PediFoods/Views/Main/CartView.swift index 5ccb722..4ba0420 100644 --- a/Sources/PediFoods/Views/Main/CartView.swift +++ b/Sources/PediFoods/Views/Main/CartView.swift @@ -287,7 +287,7 @@ struct CartView: View { defer { isLoadingDeliveryFee = false } do { - let profileResponse = try await ApiService().profile() + let profileResponse = try await ApiService().profile(forceRefresh: true) let addresses = profileResponse.result?.addressBook ?? [] if let selectedId = appState.address.selectedId, selectedId.isEmpty == false { @@ -312,20 +312,30 @@ struct CartView: View { } if let selected = selectedCustomerAddress { - appState.address.selectedId = selected.id - let label = (selected.label ?? "").trimmingCharacters(in: .whitespacesAndNewlines) - if label.isEmpty == false { - appState.address.display = label + // appState.address reflects the address the user just picked — + // authoritative. Only fill in gaps from the address book here, + // never overwrite a live selection with a (possibly stale) + // cached record, or the fee/validation payload below can end + // up built against the wrong coordinates. + if appState.address.selectedId == nil || appState.address.selectedId?.isEmpty == true { + appState.address.selectedId = selected.id } - if let lat = selected.latLong?.first, let lng = selected.latLong?.dropFirst().first { + if appState.address.display.isEmpty || appState.address.display == "Defina seu endereco" { + let label = (selected.label ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + if label.isEmpty == false { + appState.address.display = label + } + } + if appState.address.latitude == nil || appState.address.longitude == nil, + let lat = selected.latLong?.first, let lng = selected.latLong?.dropFirst().first { appState.address.latitude = lat appState.address.longitude = lng } SessionStateStore.saveAddress(appState.address) } - let payloadLat = selectedCustomerAddress?.latLong?.first ?? appState.address.latitude - let payloadLng = selectedCustomerAddress?.latLong?.dropFirst().first ?? appState.address.longitude + let payloadLat = appState.address.latitude ?? selectedCustomerAddress?.latLong?.first + let payloadLng = appState.address.longitude ?? selectedCustomerAddress?.latLong?.dropFirst().first let payload = ValidateDeliveryAddressPayload( address: ValidateDeliveryAddressDataPayload( diff --git a/Sources/PediFoods/Views/Main/CheckoutView+Logic.swift b/Sources/PediFoods/Views/Main/CheckoutView+Logic.swift index 02fabab..2d67163 100644 --- a/Sources/PediFoods/Views/Main/CheckoutView+Logic.swift +++ b/Sources/PediFoods/Views/Main/CheckoutView+Logic.swift @@ -71,7 +71,7 @@ extension CheckoutView { @MainActor func refreshSelectedCustomerAddress() async { do { - let response = try await ApiService().profile() + let response = try await ApiService().profile(forceRefresh: true) if let customer = response.result { appState.profile.id = customer.id appState.profile.name = customer.name @@ -117,12 +117,23 @@ extension CheckoutView { } if let selected = selectedCustomerAddress { - appState.address.selectedId = selected.id - let label = (selected.label ?? "").trimmingCharacters(in: .whitespacesAndNewlines) - if label.isEmpty == false { - appState.address.display = label + // appState.address reflects the address the user just picked — + // authoritative. Only fill in gaps from the address book here, + // never overwrite a live selection with a (possibly stale) + // cached record, or delivery validation below runs against + // the wrong coordinates and can wrongly report the address + // as not served, reverting the user's pick. + if appState.address.selectedId == nil || appState.address.selectedId?.isEmpty == true { + appState.address.selectedId = selected.id } - if let lat = selected.latLong?.first, let lng = selected.latLong?.dropFirst().first { + if appState.address.display.isEmpty || appState.address.display == "Defina seu endereco" { + let label = (selected.label ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + if label.isEmpty == false { + appState.address.display = label + } + } + if appState.address.latitude == nil || appState.address.longitude == nil, + let lat = selected.latLong?.first, let lng = selected.latLong?.dropFirst().first { appState.address.latitude = lat appState.address.longitude = lng } From f7064928239827d312db62580f5707a303fc9656 Mon Sep 17 00:00:00 2001 From: Daniel Arantes Loverde Date: Fri, 10 Jul 2026 10:49:18 -0300 Subject: [PATCH 2/5] [cart-checkout] Match selected address by coordinates before label, not after MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CustomerAddress.id is genuinely optional (Services/ApiModels.swift:46) — some address book entries have no id. AddressesView.selectAddress sets appState.address.selectedId = address.id directly with no fallback, so picking one of those addresses leaves selectedId nil. The matching cascade in both CartView and CheckoutView+Logic then skipped straight to a label match, which silently collides whenever two addresses share an empty or duplicate label (common for unnamed entries), and finally fell back to addresses.first — always redisplaying whatever's first in the list regardless of what was tapped, with no error surfaced anywhere. Coordinates are set immediately and reliably at selection time and are far less likely to collide than a label. Checkout already had a lat/lng fallback but ordered after the weak label match; promoted it ahead of label matching in both files, and added the same fallback to Cart, which didn't have one at all. --- Sources/PediFoods/Views/Main/CartView.swift | 15 +++++++++++ .../Views/Main/CheckoutView+Logic.swift | 25 ++++++++++++------- 2 files changed, 31 insertions(+), 9 deletions(-) diff --git a/Sources/PediFoods/Views/Main/CartView.swift b/Sources/PediFoods/Views/Main/CartView.swift index 4ba0420..d104ae3 100644 --- a/Sources/PediFoods/Views/Main/CartView.swift +++ b/Sources/PediFoods/Views/Main/CartView.swift @@ -296,6 +296,21 @@ struct CartView: View { selectedCustomerAddress = nil } + // id can be nil for some address book entries — lat/lng is set + // immediately and reliably at selection time (AddressesView. + // selectAddress), so it's a stronger signal than the label match + // below, which silently collides whenever two addresses share an + // empty/duplicate label. Without this, an id-less address falls + // through to addresses.first and never actually "changes". + if selectedCustomerAddress == nil, + let lat = appState.address.latitude, let lng = appState.address.longitude { + selectedCustomerAddress = addresses.first { address in + guard let addrLat = address.latLong?.first, + let addrLng = address.latLong?.dropFirst().first else { return false } + return abs(addrLat - lat) < 0.00001 && abs(addrLng - lng) < 0.00001 + } + } + if selectedCustomerAddress == nil { let display = appState.address.display .trimmingCharacters(in: .whitespacesAndNewlines) diff --git a/Sources/PediFoods/Views/Main/CheckoutView+Logic.swift b/Sources/PediFoods/Views/Main/CheckoutView+Logic.swift index 2d67163..035c256 100644 --- a/Sources/PediFoods/Views/Main/CheckoutView+Logic.swift +++ b/Sources/PediFoods/Views/Main/CheckoutView+Logic.swift @@ -92,6 +92,22 @@ extension CheckoutView { selectedCustomerAddress = nil } + // id can be nil for some address book entries — lat/lng is set + // immediately and reliably at selection time (AddressesView. + // selectAddress), so it's a stronger signal than the label match + // below, which silently collides whenever two addresses share an + // empty/duplicate label. Without this ordered first, an id-less + // address falls through to addresses.first and never actually + // "changes" even though delivery to it is allowed. + if selectedCustomerAddress == nil, + let lat = appState.address.latitude, let lng = appState.address.longitude { + selectedCustomerAddress = addresses.first { address in + guard let addrLat = address.latLong?.first, + let addrLng = address.latLong?.dropFirst().first else { return false } + return abs(addrLat - lat) < 0.00001 && abs(addrLng - lng) < 0.00001 + } + } + if selectedCustomerAddress == nil { let display = appState.address.display .trimmingCharacters(in: .whitespacesAndNewlines) @@ -103,15 +119,6 @@ extension CheckoutView { } } - if selectedCustomerAddress == nil, - let lat = appState.address.latitude, let lng = appState.address.longitude { - selectedCustomerAddress = addresses.first { address in - guard let addrLat = address.latLong?.first, - let addrLng = address.latLong?.dropFirst().first else { return false } - return abs(addrLat - lat) < 0.00001 && abs(addrLng - lng) < 0.00001 - } - } - if selectedCustomerAddress == nil { selectedCustomerAddress = addresses.first } From 6b777752381d2bfaa231b14c75cd6bd24faaf615 Mon Sep 17 00:00:00 2001 From: Daniel Arantes Loverde Date: Fri, 10 Jul 2026 10:55:28 -0300 Subject: [PATCH 3/5] [cart-checkout] Geocode addresses locally when lat/long is missing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Explains why the delivery fee specifically never changed while other address info (label, street) updated fine after the previous fix: AddAddressFormView only sets latLong when the CEP lookup happens to return coordinates (AddAddressFormView.swift:141-146) — plenty of saved addresses have none. Without coordinates the backend can't distinguish that address from the previous one, so the fee (and, in Checkout, checkoutAddressWatchKey itself) never actually changes, without any error surfacing since it likely falls back to some default fee instead of rejecting. Added LocationService.geocodeAddress(street:number:neighborhood:city: state:zip:), a thin CLGeocoder wrapper, and call it in both CartView.refreshDeliveryFee and CheckoutView+Logic. validateDeliveryAddressIfNeeded whenever coordinates are missing, persisting the result back into appState.address so it doesn't need to re-geocode on every subsequent check. --- .../PediFoods/Services/LocationService.swift | 33 +++++++++++++++++++ Sources/PediFoods/Views/Main/CartView.swift | 25 ++++++++++++-- .../Views/Main/CheckoutView+Logic.swift | 19 +++++++++++ 3 files changed, 75 insertions(+), 2 deletions(-) diff --git a/Sources/PediFoods/Services/LocationService.swift b/Sources/PediFoods/Services/LocationService.swift index 11cd212..43d177e 100644 --- a/Sources/PediFoods/Services/LocationService.swift +++ b/Sources/PediFoods/Services/LocationService.swift @@ -75,6 +75,39 @@ final class LocationService: NSObject { #endif } + /// Forward-geocodes a street address into coordinates. Used as a fallback + /// when a saved CustomerAddress has no lat/long (e.g. the CEP lookup at + /// creation time didn't return coordinates) — without this, delivery fee + /// validation silently can't distinguish that address from any other. + static func geocodeAddress( + street: String?, + number: String?, + neighborhood: String?, + city: String?, + state: String?, + zip: String? + ) async -> (Double, Double)? { +#if os(iOS) + let parts = [street, number, neighborhood, city, state, zip] + .compactMap { $0?.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { $0.isEmpty == false } + guard parts.isEmpty == false else { return nil } + let fullAddress = parts.joined(separator: ", ") + + return await withCheckedContinuation { continuation in + CLGeocoder().geocodeAddressString(fullAddress) { placemarks, error in + guard error == nil, let coordinate = placemarks?.first?.location?.coordinate else { + continuation.resume(returning: nil) + return + } + continuation.resume(returning: (coordinate.latitude, coordinate.longitude)) + } + } +#else + return nil +#endif + } + func requestLocationAsync(timeoutSeconds: TimeInterval = 8) async -> (Double, Double)? { await withCheckedContinuation { continuation in var hasResumed = false diff --git a/Sources/PediFoods/Views/Main/CartView.swift b/Sources/PediFoods/Views/Main/CartView.swift index d104ae3..00e93aa 100644 --- a/Sources/PediFoods/Views/Main/CartView.swift +++ b/Sources/PediFoods/Views/Main/CartView.swift @@ -349,8 +349,29 @@ struct CartView: View { SessionStateStore.saveAddress(appState.address) } - let payloadLat = appState.address.latitude ?? selectedCustomerAddress?.latLong?.first - let payloadLng = appState.address.longitude ?? selectedCustomerAddress?.latLong?.dropFirst().first + var payloadLat = appState.address.latitude ?? selectedCustomerAddress?.latLong?.first + var payloadLng = appState.address.longitude ?? selectedCustomerAddress?.latLong?.dropFirst().first + + // Some saved addresses have no lat/long (CEP lookup at creation + // time didn't return coordinates). Without coordinates the + // backend can't tell this address apart from any other, so the + // fee silently never changes. Geocode locally as a fallback. + if payloadLat == nil || payloadLng == nil { + if let coordinate = await LocationService.geocodeAddress( + street: selectedCustomerAddress?.address, + number: selectedCustomerAddress?.number, + neighborhood: selectedCustomerAddress?.neighborhood, + city: selectedCustomerAddress?.city, + state: selectedCustomerAddress?.state, + zip: selectedCustomerAddress?.zipCode + ) { + payloadLat = coordinate.0 + payloadLng = coordinate.1 + appState.address.latitude = coordinate.0 + appState.address.longitude = coordinate.1 + SessionStateStore.saveAddress(appState.address) + } + } let payload = ValidateDeliveryAddressPayload( address: ValidateDeliveryAddressDataPayload( diff --git a/Sources/PediFoods/Views/Main/CheckoutView+Logic.swift b/Sources/PediFoods/Views/Main/CheckoutView+Logic.swift index 035c256..21151f3 100644 --- a/Sources/PediFoods/Views/Main/CheckoutView+Logic.swift +++ b/Sources/PediFoods/Views/Main/CheckoutView+Logic.swift @@ -164,6 +164,25 @@ extension CheckoutView { baseDeliveryFee = nil + // Some saved addresses have no lat/long (CEP lookup at creation time + // didn't return coordinates). Without coordinates the backend can't + // tell this address apart from any other, so the fee silently never + // changes. Geocode locally as a fallback before validating. + if appState.address.latitude == nil || appState.address.longitude == nil { + if let coordinate = await LocationService.geocodeAddress( + street: selectedCustomerAddress?.address, + number: selectedCustomerAddress?.number, + neighborhood: selectedCustomerAddress?.neighborhood, + city: selectedCustomerAddress?.city, + state: selectedCustomerAddress?.state, + zip: selectedCustomerAddress?.zipCode + ) { + appState.address.latitude = coordinate.0 + appState.address.longitude = coordinate.1 + SessionStateStore.saveAddress(appState.address) + } + } + let payload = ValidateDeliveryAddressPayload( address: ValidateDeliveryAddressDataPayload( street: selectedCustomerAddress?.address, From a5878b832620dbebc08899591d01085a186765cd Mon Sep 17 00:00:00 2001 From: Daniel Arantes Loverde Date: Fri, 10 Jul 2026 11:37:02 -0300 Subject: [PATCH 4/5] [cart-checkout] Fix address name hardcoded to 'Casa' in Checkout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CheckoutView.swift's addressSection showed the literal string "Casa" as the address name/label regardless of which address was actually selected — only the street/detail line below it (customerAddressLabel) was wired to real state. Added customerAddressName, preferring selectedCustomerAddress?.label then appState.address.display, matching the same fallback pattern already used for the detail line. --- Sources/PediFoods/Views/Main/CheckoutView.swift | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/Sources/PediFoods/Views/Main/CheckoutView.swift b/Sources/PediFoods/Views/Main/CheckoutView.swift index 181023c..fbbe7f6 100644 --- a/Sources/PediFoods/Views/Main/CheckoutView.swift +++ b/Sources/PediFoods/Views/Main/CheckoutView.swift @@ -297,7 +297,7 @@ struct CheckoutView: View { ) VStack(alignment: .leading, spacing: 4) { - Text(isDeliveryMode ? "Casa" : (appState.cart.storeName ?? "Loja")) + Text(isDeliveryMode ? customerAddressName : (appState.cart.storeName ?? "Loja")) .font(AppTypography.heading3) .foregroundStyle(AppColors.textPrimary) Text(isDeliveryMode ? customerAddressLabel : storeAddressLabel) @@ -557,6 +557,16 @@ struct CheckoutView: View { } } + private var customerAddressName: String { + let label = (selectedCustomerAddress?.label ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + if label.isEmpty == false { return label } + + let displayLabel = appState.address.display.trimmingCharacters(in: .whitespacesAndNewlines) + if displayLabel.isEmpty == false, displayLabel != "Defina seu endereco" { return displayLabel } + + return "Endereço" + } + private var customerAddressLabel: String { if let address = selectedCustomerAddress { let street = (address.address ?? "").trimmingCharacters(in: .whitespacesAndNewlines) From fc71228ad5e1c19a55e293d9999d313170afd3ab Mon Sep 17 00:00:00 2001 From: Daniel Arantes Loverde Date: Fri, 10 Jul 2026 11:43:15 -0300 Subject: [PATCH 5/5] [cart-checkout] Prefer live appState.address.display over async selectedCustomerAddress for the name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Same class of bug as the earlier matching-cascade fix: selectedCustomerAddress is resolved asynchronously against the address book and can lag behind or mismatch. appState.address.display is set synchronously the moment the user picks an address (AddressesView.selectAddress) — it's the authoritative live value. customerAddressName had these backwards, checking the async value first. --- Sources/PediFoods/Views/Main/CheckoutView.swift | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/Sources/PediFoods/Views/Main/CheckoutView.swift b/Sources/PediFoods/Views/Main/CheckoutView.swift index fbbe7f6..0c692bd 100644 --- a/Sources/PediFoods/Views/Main/CheckoutView.swift +++ b/Sources/PediFoods/Views/Main/CheckoutView.swift @@ -558,12 +558,19 @@ struct CheckoutView: View { } private var customerAddressName: String { - let label = (selectedCustomerAddress?.label ?? "").trimmingCharacters(in: .whitespacesAndNewlines) - if label.isEmpty == false { return label } - + // appState.address.display is set directly, synchronously, the + // moment the user picks an address (AddressesView.selectAddress) — + // it's live. selectedCustomerAddress is resolved later via an async + // matching cascade against the address book and can briefly (or, + // if matching goes wrong, persistently) lag behind or resolve to + // the wrong entry. Prefer the live value; the async one is only a + // fallback for when nothing's been picked yet this session. let displayLabel = appState.address.display.trimmingCharacters(in: .whitespacesAndNewlines) if displayLabel.isEmpty == false, displayLabel != "Defina seu endereco" { return displayLabel } + let label = (selectedCustomerAddress?.label ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + if label.isEmpty == false { return label } + return "Endereço" }