From a0e4375914cfe8a901a5aa0b41ffc113dcedc105 Mon Sep 17 00:00:00 2001 From: Daniel Arantes Loverde Date: Thu, 27 Aug 2026 16:33:01 -0300 Subject: [PATCH] [public-store-distance] Consume the public distance contract, stop labelling it as unavailable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Guest browsing showed 'Distância indisponível' on every store card and a '--' tile on store detail, because the public locator response carries no distance and StoreSummary.init(publicItem:) hardcoded nil. Backend contract (docs/plans/public-store-distance-consumer.md): distance is a number, never null, 0 means unavailable. Implemented app-side ahead of the backend - every change is forward-compatible, and the part that removes the broken label works with no backend at all. - PublicStoreListItem / PublicStoreDetail: + distance (optional, so today's responses without the field still decode). - StoreSummary.init(publicItem:): normalize the 0 sentinel to nil, so one representation of 'unknown' reaches the label and the max-distance filter. - fetchStoreDetail: send state/city from GuestLocationStore by default - the server needs them to resolve the city centroid. - formatDistance: empty string for nil/0/negative. It previously returned 'Distância indisponível' for nil, and - found by the new test - '0 m' for 0 and '-1000 m' for a negative. - StoreCard: drop the distance segment and its '·' separator together, otherwise the row ended in a dangling separator. - StoreDetailView: drop the DISTÂNCIA tile and its divider instead of '--'. Also fixes the same label in the authenticated flow when the user declined location and has no address coordinates. Tests: PublicStoreDistanceTests (decode with/without the field, passthrough, 0 normalization) + the empty case in HomeViewFilteringTests. --- PediFoods.xcodeproj/project.pbxproj | 43 ++++++++++------ PediFoods/Components/StoreCard.swift | 17 ++++--- PediFoods/Services/PublicLocationModels.swift | 9 ++++ .../Services/PublicLocationService.swift | 20 +++++++- PediFoods/Views/Main/HomeView+Data.swift | 7 ++- PediFoods/Views/Main/HomeView+Filtering.swift | 7 ++- .../Main/StoreDetailView+Components.swift | 9 +++- .../Views/Main/StoreDetailView+Logic.swift | 12 +++-- PediFoodsTests/HomeViewFilteringTests.swift | 14 +++++- PediFoodsTests/PublicStoreDistanceTests.swift | 49 +++++++++++++++++++ 10 files changed, 154 insertions(+), 33 deletions(-) create mode 100644 PediFoodsTests/PublicStoreDistanceTests.swift diff --git a/PediFoods.xcodeproj/project.pbxproj b/PediFoods.xcodeproj/project.pbxproj index c8ecfd9..60e9941 100644 --- a/PediFoods.xcodeproj/project.pbxproj +++ b/PediFoods.xcodeproj/project.pbxproj @@ -3,7 +3,7 @@ archiveVersion = 1; classes = { }; - objectVersion = 56; + objectVersion = 54; objects = { /* Begin PBXBuildFile section */ @@ -79,9 +79,10 @@ 7CC171B83BC4CDEC38B25F8C /* SnackbarCenterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2CE116C585A7529CF309B3D /* SnackbarCenterTests.swift */; }; 7D7BF04765D6CA6AC4F11DDD /* StoreCatalogNormalizerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 18EC38EEE1E7105BC21E354C /* StoreCatalogNormalizerTests.swift */; }; 80B476CCE2B90BEE2FA4CB37 /* PediFoodsUITestsLaunchTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 56E3E6C1834786CA3621B7BB /* PediFoodsUITestsLaunchTests.swift */; }; - 811217F88AA350A70CC22479 /* NotificationService.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = C724E02EC34BB1D4352C2FF0 /* NotificationService.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; + 811217F88AA350A70CC22479 /* NotificationServiceExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = C724E02EC34BB1D4352C2FF0 /* NotificationServiceExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; 85EA52253BABA327EC07CFF0 /* AuthFormattersTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 561E00C486D60EC2DF720EAF /* AuthFormattersTests.swift */; }; 8D6622AE9325D02B830BD115 /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 6CAA9B253B1A6C8815A0E69D /* LaunchScreen.storyboard */; }; + 901A6A5A00C18FC59F764B85 /* PublicStoreDistanceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E55893DD92CBFB24F212454 /* PublicStoreDistanceTests.swift */; }; 90ADA376585F5438F3A1A1D6 /* HomeView+Favorites.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1517B141263438E466452037 /* HomeView+Favorites.swift */; }; 918BCBC1EAD7EE134DB9742D /* CheckoutTypesTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8FF15CEE5B137074E6B2489 /* CheckoutTypesTests.swift */; }; 926E0C4DD3C82485D4262A62 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 29B2F3558C58CD1E71F0F3A0 /* ContentView.swift */; }; @@ -168,7 +169,7 @@ dstPath = ""; dstSubfolderSpec = 13; files = ( - 811217F88AA350A70CC22479 /* NotificationService.appex in Embed Foundation Extensions */, + 811217F88AA350A70CC22479 /* NotificationServiceExtension.appex in Embed Foundation Extensions */, ); name = "Embed Foundation Extensions"; runOnlyForDeploymentPostprocessing = 0; @@ -210,6 +211,7 @@ 4C415019CC1F314EE7318864 /* ApiCardModelsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApiCardModelsTests.swift; sourceTree = ""; }; 4DC5416AEBA77C43CF55ABA8 /* PublicLocationPickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PublicLocationPickerView.swift; sourceTree = ""; }; 4E0CCEF22D356F9EC723999D /* HomeView+Data.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "HomeView+Data.swift"; sourceTree = ""; }; + 4E55893DD92CBFB24F212454 /* PublicStoreDistanceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PublicStoreDistanceTests.swift; sourceTree = ""; }; 507E868458359BE0E4FF25F1 /* ApiService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApiService.swift; sourceTree = ""; }; 52BFF93F21509F1AD4F6676B /* PediFoodsApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PediFoodsApp.swift; sourceTree = ""; }; 54795EDED987426386226358 /* GuestLocationStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GuestLocationStoreTests.swift; sourceTree = ""; }; @@ -228,7 +230,7 @@ 69A9FB3DE679C3D4C60E7E42 /* ApiConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApiConfig.swift; sourceTree = ""; }; 6BF467DF1447525F04B242B0 /* ReviewsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReviewsView.swift; sourceTree = ""; }; 6CAA9B253B1A6C8815A0E69D /* LaunchScreen.storyboard */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; path = LaunchScreen.storyboard; sourceTree = ""; }; - 6DD1D8390E57439A347F9E07 /* PediFoodsTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = PediFoodsTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 6DD1D8390E57439A347F9E07 /* PediFoodsTests.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = PediFoodsTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 6EC381D02A37312BE360024F /* Buttons.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Buttons.swift; sourceTree = ""; }; 6FC454261845C1EDF970552A /* CheckoutTypes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CheckoutTypes.swift; sourceTree = ""; }; 7034E89267D9F5D86BF1E424 /* CartCheckoutFlowTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CartCheckoutFlowTests.swift; sourceTree = ""; }; @@ -241,7 +243,7 @@ 76B24E69A3F6A3EB86449870 /* AddAddressFormView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddAddressFormView.swift; sourceTree = ""; }; 794249487F718BCFBF176E8D /* ApiModelsDecodingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApiModelsDecodingTests.swift; sourceTree = ""; }; 79D2854B8062D96742CFDD99 /* HomeViewComponents.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeViewComponents.swift; sourceTree = ""; }; - 7A6D74FD2D2F2F79349EA04E /* PediFoods.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = PediFoods.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 7A6D74FD2D2F2F79349EA04E /* PediFoods.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = PediFoods.app; sourceTree = BUILT_PRODUCTS_DIR; }; 7A9F14B1BDD7747C546DFAF0 /* GuestSessionService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GuestSessionService.swift; sourceTree = ""; }; 7C13751F450A42A2BDB0783D /* ProfileLoggedOutFlowTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileLoggedOutFlowTests.swift; sourceTree = ""; }; 7CD06F9A7AF9365D1FBE35D0 /* AddressComponents.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddressComponents.swift; sourceTree = ""; }; @@ -250,7 +252,7 @@ 89B662E8B7E1B8F1CF7E657E /* KeychainStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeychainStore.swift; sourceTree = ""; }; 8A3FB85A2A2E2952B9A2BAB0 /* DeepLinkDestination.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeepLinkDestination.swift; sourceTree = ""; }; 8AA914FB8B32B106911FD67B /* LegalDocumentTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LegalDocumentTests.swift; sourceTree = ""; }; - 8AF987AB319512C123DFD558 /* PediFoodsUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = PediFoodsUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 8AF987AB319512C123DFD558 /* PediFoodsUITests.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = PediFoodsUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 8D2C6A0E85E133F947293181 /* OrderDetailsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OrderDetailsView.swift; sourceTree = ""; }; 8DD2B73D89E6D33643EF58F5 /* DeepLinkRouteEffectTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeepLinkRouteEffectTests.swift; sourceTree = ""; }; 8E3A0A2E253ABFCA35D02F28 /* TextFieldCompat.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextFieldCompat.swift; sourceTree = ""; }; @@ -285,7 +287,7 @@ C44662BAA20AD862BE1DBC40 /* PublicLocationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PublicLocationService.swift; sourceTree = ""; }; C4745C12F14B695C77DFE178 /* SnackbarOverlay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SnackbarOverlay.swift; sourceTree = ""; }; C6B08901A67C8D90A3B50882 /* ApiCustomerPayloadModelsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApiCustomerPayloadModelsTests.swift; sourceTree = ""; }; - C724E02EC34BB1D4352C2FF0 /* NotificationService.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = NotificationService.appex; sourceTree = BUILT_PRODUCTS_DIR; }; + C724E02EC34BB1D4352C2FF0 /* NotificationServiceExtension.appex */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = "wrapper.app-extension"; path = NotificationServiceExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; CA44F5384D21B7F718F95522 /* StoreDetailSupport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoreDetailSupport.swift; sourceTree = ""; }; CAB9F980426D39E93E091051 /* LocationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationService.swift; sourceTree = ""; }; CD5EE81AE4347B4086684F8B /* CartView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CartView.swift; sourceTree = ""; }; @@ -344,7 +346,7 @@ 126A579C448C784867DC3AB1 /* Products */ = { isa = PBXGroup; children = ( - C724E02EC34BB1D4352C2FF0 /* NotificationService.appex */, + C724E02EC34BB1D4352C2FF0 /* NotificationServiceExtension.appex */, 7A6D74FD2D2F2F79349EA04E /* PediFoods.app */, 6DD1D8390E57439A347F9E07 /* PediFoodsTests.xctest */, 8AF987AB319512C123DFD558 /* PediFoodsUITests.xctest */, @@ -446,6 +448,7 @@ 23B6B814027518969EB04341 /* HomeViewFilteringTests.swift */, 5A82497C63D507E82FE02693 /* ImageSourceResolverTests.swift */, 8AA914FB8B32B106911FD67B /* LegalDocumentTests.swift */, + 4E55893DD92CBFB24F212454 /* PublicStoreDistanceTests.swift */, 747AD255B6D881E011FDF907 /* SessionStateStoreTests.swift */, 160571AB2F8D4DF98C0BE16F /* SmokeTests.swift */, B2CE116C585A7529CF309B3D /* SnackbarCenterTests.swift */, @@ -621,7 +624,7 @@ ); name = NotificationServiceExtension; productName = NotificationServiceExtension; - productReference = C724E02EC34BB1D4352C2FF0 /* NotificationService.appex */; + productReference = C724E02EC34BB1D4352C2FF0 /* NotificationServiceExtension.appex */; productType = "com.apple.product-type.app-extension"; }; 66486D25E3A8A75EA8FACE44 /* PediFoodsUITests */ = { @@ -671,10 +674,21 @@ BuildIndependentTargetsInParallel = YES; LastUpgradeCheck = 1430; TargetAttributes = { + 3027BAE353B414EC70E2A2AC = { + DevelopmentTeam = ""; + ProvisioningStyle = Manual; + }; + 33EB2407F0F758EB98A769D3 = { + DevelopmentTeam = ""; + ProvisioningStyle = Manual; + }; 66486D25E3A8A75EA8FACE44 = { + DevelopmentTeam = K4E5BZMM4V; TestTargetID = B3B5CFF77A8ED019D1299ABB; }; B3B5CFF77A8ED019D1299ABB = { + DevelopmentTeam = ""; + ProvisioningStyle = Manual; SystemCapabilities = { com.apple.Push = { enabled = YES; @@ -867,6 +881,7 @@ 63AB40542AABCC3CB2390B87 /* HomeViewFilteringTests.swift in Sources */, 22E3BF78B8C2B22D14EABE8B /* ImageSourceResolverTests.swift in Sources */, 2FCB3403A764AD74AC3BD47C /* LegalDocumentTests.swift in Sources */, + 901A6A5A00C18FC59F764B85 /* PublicStoreDistanceTests.swift in Sources */, F760557D906B860D0EA277DE /* SessionStateStoreTests.swift in Sources */, B24B898508C8A8EF4191EC89 /* SmokeTests.swift in Sources */, 7CC171B83BC4CDEC38B25F8C /* SnackbarCenterTests.swift in Sources */, @@ -986,7 +1001,6 @@ 388EFAF707AD295240B9E951 /* Release */ = { isa = XCBuildConfiguration; buildSettings = { - APP_ATTEST_ENVIRONMENT = production; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; @@ -994,7 +1008,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 2026.0.25.0.0.1; + CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = ""; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = K4E5BZMM4V; ENABLE_PREVIEWS = YES; @@ -1002,7 +1016,7 @@ INFOPLIST_FILE = PediFoods/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = "Pedi Foods"; INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO; - INFOPLIST_KEY_NSLocationWhenInUseUsageDescription = "PediFoods usa sua localização para mostrar lojas perto de você"; + INFOPLIST_KEY_NSLocationWhenInUseUsageDescription = "PediFoods uses your location to show nearby stores and estimate delivery times."; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = NO; @@ -1031,7 +1045,6 @@ 4424276379B7A6A98892CFBC /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { - APP_ATTEST_ENVIRONMENT = development; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; @@ -1039,7 +1052,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 2026.0.25.0.0.1; + CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = ""; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = K4E5BZMM4V; ENABLE_PREVIEWS = YES; @@ -1047,7 +1060,7 @@ INFOPLIST_FILE = PediFoods/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = "Pedi Foods"; INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO; - INFOPLIST_KEY_NSLocationWhenInUseUsageDescription = "PediFoods usa sua localização para mostrar lojas perto de você"; + INFOPLIST_KEY_NSLocationWhenInUseUsageDescription = "PediFoods uses your location to show nearby stores and estimate delivery times."; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = NO; diff --git a/PediFoods/Components/StoreCard.swift b/PediFoods/Components/StoreCard.swift index df66098..5c24c9c 100644 --- a/PediFoods/Components/StoreCard.swift +++ b/PediFoods/Components/StoreCard.swift @@ -53,12 +53,17 @@ struct FeaturedStoreCard: View { Text("(\(store.reviews))") .font(.caption) .foregroundStyle(AppColors.textMuted) - Text("·") - .font(.caption) - .foregroundStyle(AppColors.textMuted) - Text(store.distance) - .font(.caption) - .foregroundStyle(AppColors.textMuted) + // Empty distance means "unavailable" - drop the separator + // with it, otherwise the row ends in a dangling "·". + // See docs/plans/public-store-distance-consumer.md. + if store.distance.isEmpty == false { + Text("·") + .font(.caption) + .foregroundStyle(AppColors.textMuted) + Text(store.distance) + .font(.caption) + .foregroundStyle(AppColors.textMuted) + } } } else { Text(store.statusLabel?.isEmpty == false ? (store.statusLabel ?? "Fechado") : "Fechado") diff --git a/PediFoods/Services/PublicLocationModels.swift b/PediFoods/Services/PublicLocationModels.swift index 516c2a2..f563acb 100644 --- a/PediFoods/Services/PublicLocationModels.swift +++ b/PediFoods/Services/PublicLocationModels.swift @@ -28,6 +28,12 @@ struct PublicStoreListItem: Decodable, Sendable, Identifiable { let deliveryTime: String? let deliveryFee: Double? let minOrder: Double? + /// Kilometres from the picked city's centroid to the store - always + /// approximate, since the origin is the city rather than the visitor. + /// The server sends `0` for "unavailable" and never null; optional here + /// only so the app keeps decoding responses from before the field + /// shipped. See docs/plans/public-store-distance-consumer.md. + let distance: Double? } struct PublicStoreDetail: Decodable, Sendable { @@ -56,6 +62,9 @@ struct PublicStoreDetail: Decodable, Sendable { let deliveryPrice: Double? let minOrder: Double? let acceptPix: Bool? + /// Same contract as `PublicStoreListItem.distance`, but only populated + /// when the detail request carries `state`/`city`. + let distance: Double? } // Maps the public (anonymous) store-detail projection onto the same models diff --git a/PediFoods/Services/PublicLocationService.swift b/PediFoods/Services/PublicLocationService.swift index af0eade..e5da6c5 100644 --- a/PediFoods/Services/PublicLocationService.swift +++ b/PediFoods/Services/PublicLocationService.swift @@ -34,13 +34,31 @@ final class PublicLocationService: @unchecked Sendable { } /// No guest token — this route is fully public/unauthenticated per the doc. - func fetchStoreDetail(identifier: String) async throws -> PublicStoreDetail { + /// + /// `state`/`city` are optional on the server and are what let it resolve + /// the city centroid the store `distance` is measured from; without them + /// the response carries `distance: 0` (see + /// docs/plans/public-store-distance-consumer.md). Defaults to whatever the + /// visitor picked in "ENTREGAR EM:". + func fetchStoreDetail( + identifier: String, + state: String? = GuestLocationStore.shared.selectedState, + city: String? = GuestLocationStore.shared.selectedCity + ) async throws -> PublicStoreDetail { let encodedIdentifier = identifier.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? identifier + var query: [URLQueryItem] = [] + if let state, state.isEmpty == false { + query.append(URLQueryItem(name: "state", value: state)) + } + if let city, city.isEmpty == false { + query.append(URLQueryItem(name: "city", value: city)) + } let req = ApiRequest( path: "/api/public/store/\(encodedIdentifier)", method: "GET", module: .none, requiresAuth: false, + queryItems: query, baseURLOverride: ApiConfig.pediFoodsBFFURL ) let envelope: ApiEnvelope = try await client.send(req) diff --git a/PediFoods/Views/Main/HomeView+Data.swift b/PediFoods/Views/Main/HomeView+Data.swift index 2c1510b..8405acf 100644 --- a/PediFoods/Views/Main/HomeView+Data.swift +++ b/PediFoods/Views/Main/HomeView+Data.swift @@ -177,7 +177,7 @@ extension HomeView { extension StoreSummary { /// Maps the public-locator DTO onto the same model HomeView already - /// renders — distance/positiveReviews don't exist in that response. + /// renders — positiveReviews doesn't exist in that response. init(publicItem: PublicStoreListItem) { self.id = publicItem.id self.name = publicItem.name ?? "Loja" @@ -189,7 +189,10 @@ extension StoreSummary { self.positiveReviews = nil self.deliveryTime = publicItem.deliveryTime self.deliveryFee = publicItem.deliveryFee - self.distance = nil + // The contract uses `0` for "no distance available"; normalize it to + // nil here so every downstream consumer (card label, max-distance + // filter) sees one representation of "unknown". + self.distance = (publicItem.distance ?? 0) > 0 ? publicItem.distance : nil self.isOpen = publicItem.isOpen self.statusLabel = publicItem.statusLabel } diff --git a/PediFoods/Views/Main/HomeView+Filtering.swift b/PediFoods/Views/Main/HomeView+Filtering.swift index 7950f4b..3cab9f8 100644 --- a/PediFoods/Views/Main/HomeView+Filtering.swift +++ b/PediFoods/Views/Main/HomeView+Filtering.swift @@ -82,8 +82,13 @@ extension HomeView { resolveStoreMediaURL(appState.profile.profilePicture) } + /// Empty string means "no distance to show" - the caller omits the whole + /// segment rather than labelling it. Per the API contract + /// (docs/plans/public-store-distance-consumer.md) the server never sends + /// null and uses `0` for "unavailable", so both that and a missing value + /// collapse to the same empty result. func formatDistance(_ distance: Double?) -> String { - guard let distance else { return "Distância indisponível" } + guard let distance, distance > 0 else { return "" } if distance >= 1 { return String(format: "%.1f km", distance) } diff --git a/PediFoods/Views/Main/StoreDetailView+Components.swift b/PediFoods/Views/Main/StoreDetailView+Components.swift index ade35f9..379aa93 100644 --- a/PediFoods/Views/Main/StoreDetailView+Components.swift +++ b/PediFoods/Views/Main/StoreDetailView+Components.swift @@ -109,8 +109,13 @@ extension StoreDetailView { } HStack(spacing: 0) { - statItem(title: "DISTÂNCIA", value: distanceValueLabel) - Divider().frame(height: 34) + // No distance available: drop the tile and its divider + // instead of showing a placeholder - see + // docs/plans/public-store-distance-consumer.md. + if hasStoreDistance { + statItem(title: "DISTÂNCIA", value: distanceValueLabel) + Divider().frame(height: 34) + } if let deliveryTime = info?.deliveryTime { statItem(title: "TEMPO MIN.", value: deliveryTime+" min.") } else { diff --git a/PediFoods/Views/Main/StoreDetailView+Logic.swift b/PediFoods/Views/Main/StoreDetailView+Logic.swift index a45bedb..1e3daef 100644 --- a/PediFoods/Views/Main/StoreDetailView+Logic.swift +++ b/PediFoods/Views/Main/StoreDetailView+Logic.swift @@ -57,12 +57,14 @@ extension StoreDetailView { return "R$ --" } + /// Already formatted upstream by `HomeView.formatDistance`, which returns + /// an empty string when there's no distance to show. var distanceValueLabel: String { - let raw = (storeDistance ?? "").trimmingCharacters(in: .whitespacesAndNewlines) - if raw.isEmpty { - return "--" - } - return raw + (storeDistance ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + } + + var hasStoreDistance: Bool { + distanceValueLabel.isEmpty == false } var isStoreOpen: Bool { diff --git a/PediFoodsTests/HomeViewFilteringTests.swift b/PediFoodsTests/HomeViewFilteringTests.swift index 170f36d..3a38e82 100644 --- a/PediFoodsTests/HomeViewFilteringTests.swift +++ b/PediFoodsTests/HomeViewFilteringTests.swift @@ -63,5 +63,17 @@ func formatDistanceSwitchesUnitsAtOneKm() { #expect(view.formatDistance(0.5) == "500 m") #expect(view.formatDistance(1.0) == "1.0 km") #expect(view.formatDistance(2.3) == "2.3 km") - #expect(view.formatDistance(nil) == "Distância indisponível") +} + +/// Contract (docs/plans/public-store-distance-consumer.md): the API never +/// sends null - `0` means "no distance available". Both that and a missing +/// value render as an empty string so the caller omits the segment +/// entirely, instead of showing "Distância indisponível" or "0 m". +@Test("formatDistance renders nothing when the distance is missing or zero") +@MainActor +func formatDistanceIsEmptyWhenUnavailable() { + let view = makeHomeView() + #expect(view.formatDistance(nil) == "") + #expect(view.formatDistance(0) == "") + #expect(view.formatDistance(-1) == "") } diff --git a/PediFoodsTests/PublicStoreDistanceTests.swift b/PediFoodsTests/PublicStoreDistanceTests.swift new file mode 100644 index 0000000..f0f0da8 --- /dev/null +++ b/PediFoodsTests/PublicStoreDistanceTests.swift @@ -0,0 +1,49 @@ +import Foundation +import Testing +@testable import PediFoods + +/// Covers the public-locator distance contract documented in +/// docs/plans/public-store-distance-consumer.md: the server sends +/// `distance` as a number (never null) where `0` means "unavailable", and +/// the app collapses that to `nil` so a single representation of "unknown" +/// reaches the card label and the max-distance filter. +private func decode(_ type: T.Type, _ json: String) throws -> T { + try JSONDecoder().decode(T.self, from: Data(json.utf8)) +} + +@Test("PublicStoreListItem decodes the distance field") +func publicStoreListItemDecodesDistance() throws { + let item = try decode(PublicStoreListItem.self, #"{"id":"1","distance":3.4}"#) + #expect(item.distance == 3.4) +} + +/// The field ships on the backend after the app - a response without it +/// must still decode rather than throwing. +@Test("PublicStoreListItem still decodes a response with no distance field") +func publicStoreListItemDecodesWithoutDistance() throws { + let item = try decode(PublicStoreListItem.self, #"{"id":"1"}"#) + #expect(item.distance == nil) +} + +@Test("PublicStoreDetail decodes the distance field") +func publicStoreDetailDecodesDistance() throws { + let detail = try decode(PublicStoreDetail.self, #"{"id":"1","distance":1.2}"#) + #expect(detail.distance == 1.2) + let without = try decode(PublicStoreDetail.self, #"{"id":"1"}"#) + #expect(without.distance == nil) +} + +@Test("StoreSummary carries a real public distance through") +func storeSummaryKeepsPublicDistance() throws { + let item = try decode(PublicStoreListItem.self, #"{"id":"1","name":"A","distance":2.5}"#) + #expect(StoreSummary(publicItem: item).distance == 2.5) +} + +@Test("StoreSummary maps the unavailable sentinel and a missing distance to nil") +func storeSummaryNormalizesUnavailableDistance() throws { + let zero = try decode(PublicStoreListItem.self, #"{"id":"1","name":"A","distance":0}"#) + #expect(StoreSummary(publicItem: zero).distance == nil) + + let absent = try decode(PublicStoreListItem.self, #"{"id":"1","name":"A"}"#) + #expect(StoreSummary(publicItem: absent).distance == nil) +}