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 5ccb722..00e93aa 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 { @@ -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) @@ -312,20 +327,51 @@ 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 + 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 02fabab..21151f3 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 @@ -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,26 +119,28 @@ 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 } 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 } @@ -146,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, diff --git a/Sources/PediFoods/Views/Main/CheckoutView.swift b/Sources/PediFoods/Views/Main/CheckoutView.swift index 181023c..0c692bd 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,23 @@ struct CheckoutView: View { } } + private var customerAddressName: String { + // 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" + } + private var customerAddressLabel: String { if let address = selectedCustomerAddress { let street = (address.address ?? "").trimmingCharacters(in: .whitespacesAndNewlines)