import Foundation import SwiftUI extension HomeView { func buildCategories(from stores: [StoreSummary]) -> [CategoryModel] { var unique: [CategoryModel] = [ .init(id: "all", title: "Todas", systemIcon: "line.3.horizontal.decrease.circle", emojiIcon: nil) ] var seen = Set() for store in stores { let raw = (store.category ?? "").trimmingCharacters(in: .whitespacesAndNewlines) if raw.isEmpty { continue } let dedupe = raw.lowercased() if seen.contains(dedupe) { continue } seen.insert(dedupe) unique.append(.init(id: raw, title: raw, systemIcon: categoryIcon(for: raw), emojiIcon: nil)) } return unique } @MainActor func loadHomeCategories(withFallbackStores stores: [StoreSummary], forceRefresh: Bool = false) async { let cacheKey = "public-categories" if forceRefresh == false, let cached: [CategoryModel] = AppContentCache.shared.value(for: cacheKey, as: [CategoryModel].self) { categories = cached return } do { let response = try await ApiService().listPublicCategories(forceRefresh: forceRefresh) if response.error == false, let remote = response.result, remote.isEmpty == false { let mapped = mapPublicCategories(remote) categories = mapped AppContentCache.shared.set(mapped, for: cacheKey, ttl: AppCacheTTL.twoHours) return } } catch { // Fallback handled below. } let fallback = buildCategories(from: stores) categories = fallback AppContentCache.shared.set(fallback, for: cacheKey, ttl: AppCacheTTL.twoHours) } func mapPublicCategories(_ remote: [PublicCategory]) -> [CategoryModel] { var mapped: [CategoryModel] = [] var seen = Set() for item in remote { let id = item.id.trimmingCharacters(in: .whitespacesAndNewlines) let title = item.name.trimmingCharacters(in: .whitespacesAndNewlines) if id.isEmpty || title.isEmpty { continue } if seen.contains(id.lowercased()) { continue } seen.insert(id.lowercased()) mapped.append( .init( id: id, title: title, systemIcon: id.lowercased() == "all" ? "line.3.horizontal.decrease.circle" : nil, emojiIcon: item.icon ) ) } if mapped.contains(where: { $0.id.lowercased() == "all" }) == false { mapped.insert(.init(id: "all", title: "Todas", systemIcon: "line.3.horizontal.decrease.circle", emojiIcon: nil), at: 0) } else { mapped.sort { lhs, rhs in if lhs.id.lowercased() == "all" { return true } if rhs.id.lowercased() == "all" { return false } return lhs.title < rhs.title } } return mapped } func categoryIcon(for category: String) -> String { let value = category.folding(options: [.diacriticInsensitive, .caseInsensitive], locale: .current).lowercased() if value.contains("pizza") { return "takeoutbag.and.cup.and.straw" } if value.contains("lanche") || value.contains("hamburg") || value.contains("burger") { return "fork.knife" } if value.contains("cafe") || value.contains("breakfast") { return "sun.max" } if value.contains("doce") || value.contains("sobremesa") || value.contains("dessert") { return "cup.and.saucer" } return "storefront" } @MainActor func resolveCoordinates(forceRefresh: Bool) async -> (Double, Double)? { guard hasConfiguredAddress() else { return nil } // If user selected/saved an address, always trust its coordinates. // This avoids overriding the chosen city with current device GPS. if let lat = appState.address.latitude, let lng = appState.address.longitude { return (lat, lng) } if !forceRefresh, let cached = LocationService.shared.cachedLocation() { appState.address.latitude = cached.0 appState.address.longitude = cached.1 return cached } // Fallback to device location only when no address coordinates are available. let deviceCoordinate = await LocationService.shared.requestLocationAsync(timeoutSeconds: 3) if let deviceCoordinate { appState.address.latitude = deviceCoordinate.0 appState.address.longitude = deviceCoordinate.1 } return deviceCoordinate } func hasConfiguredAddress() -> Bool { if appState.address.selectedId != nil { return true } let normalized = appState.address.display .trimmingCharacters(in: .whitespacesAndNewlines) .lowercased() return normalized.isEmpty == false && normalized != "defina seu endereco" } func storesUserMessage(_ error: Error) -> String { if let service = error as? ApiServiceError { return service.errorDescription ?? "Não foi possível carregar os estabelecimentos." } if let network = error as? NetworkError { return network.errorDescription ?? "Não foi possível carregar os estabelecimentos." } return "Não foi possível carregar os estabelecimentos." } /// Anonymous store loading: no account, no coordinates — just the /// manually-picked state/city from the public locator. The BFF endpoint /// has no category filter, so any category chip selection is applied /// client-side via `filteredStores` (HomeView+Filtering.swift), same as /// the multi-select filters already do. @MainActor func loadGuestStores(hadExistingStores: Bool, category: String?, refreshCategories: Bool) async { guard let state = GuestLocationStore.shared.selectedState, let city = GuestLocationStore.shared.selectedCity else { isLoadingStores = false stores = [] storesError = "Escolha um estado e cidade para visualizar os estabelecimentos." appState.activeModal = .addressPicker return } do { let items = try await PublicLocationService.shared.fetchStores(state: state, city: city) isLoadingStores = false let mapped = items.map(StoreSummary.init(publicItem:)) stores = mapped if refreshCategories || (category == nil && categories.count <= 1) { await loadHomeCategories(withFallbackStores: mapped, forceRefresh: refreshCategories) if categories.contains(where: { $0.id == selectedCategory }) == false { selectedCategory = "all" } } storesError = nil } catch { if isCancelledRequest(error) { isLoadingStores = false return } isLoadingStores = false reportStoresLoadFailure(storesUserMessage(error), hadExistingStores: hadExistingStores) } } } extension StoreSummary { /// Maps the public-locator DTO onto the same model HomeView already /// renders — positiveReviews doesn't exist in that response. init(publicItem: PublicStoreListItem) { self.id = publicItem.id self.name = publicItem.name ?? "Loja" self.logo = publicItem.logo self.cover = publicItem.cover self.category = publicItem.category self.rating = publicItem.rating self.reviewsCount = publicItem.totalReviews self.positiveReviews = nil self.deliveryTime = publicItem.deliveryTime self.deliveryFee = publicItem.deliveryFee // 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 } }