import SwiftUI #if os(iOS) import LCEssentials import UIKit #endif struct HomeView: View { @Binding var appState: AppState @Binding var selectedTab: MainTab @State var searchText = "" @State var selectedCategory = "all" @State var categories: [CategoryModel] = [ .init(id: "all", title: "Todas", systemIcon: "line.3.horizontal.decrease.circle", emojiIcon: nil) ] @State var scrollOffset: CGFloat = 0 @State var hasRequestedLocation = false @State var isLoadingStores = false @State var storesError: String? = nil @State var stores: [StoreSummary] = [] @State var productSearchIndexByStoreId: [String: [String]] = [:] @State var searchDebounceToken = 0 @State var favoriteRequestStoreIds: Set = [] private let specials: [SpecialOfferCardModel] = [ // .init(id: "ddddd", title: "Get 50% OFF", subtitle: "For your first order", colors: [Color(hex: "#1E6B43"), Color(hex: "#58A56C")]), // .init(id: "eeeee", title: "Enjoy spicy day", subtitle: "Delivery in 20 min", colors: [Color(hex: "#F04B3E"), Color(hex: "#FF8C42")]) ] private let headerExpandedHeight: CGFloat = 240 private let headerCollapsedHeight: CGFloat = 120 private let contentTopSpacing: CGFloat = -40 private let contentBottomSpacing: CGFloat = 120 var body: some View { let collapseProgress = clamp(value: scrollOffset / (headerExpandedHeight - headerCollapsedHeight), lower: 0, upper: 1) let headerHeight = headerExpandedHeight - (headerExpandedHeight - headerCollapsedHeight) * collapseProgress return ZStack(alignment: .top) { ScrollView(showsIndicators: false) { contentStack .padding(.top, headerExpandedHeight + contentTopSpacing) .padding(.bottom, contentBottomSpacing) } .refreshable { // See StoreDetailView's .refreshable for why this runs in // its own unstructured Task: SwiftUI can cancel // .refreshable's own wrapping Task independent of whether // the network call is still legitimately in flight, and // that cancellation was being silently swallowed by // isCancelledRequest — awaiting Task.value decouples the // real work from that premature cancellation. await Task { await bootstrapStoresFlow( forceNetworkRefresh: true, category: selectedCategoryQueryValue, refreshCategories: true ) }.value } .appNamedCoordinateSpace(HomeScrollCoordinateSpace.name) // ignoresSafeArea lives here, on the header only — not on the // .refreshable ScrollView above. Applying it to an ancestor of // a .refreshable view (or the view itself) breaks the native // pull-to-refresh spinner's positioning, rendering it invisible // even though the gesture still fires the refresh closure. header(collapseProgress: collapseProgress, height: headerHeight) .frame(maxWidth: .infinity, alignment: .top) .ignoresSafeArea(edges: .top) } .background(AppColors.backgroundLight) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) .onAppear { if hasRequestedLocation == false { hasRequestedLocation = true Task { await bootstrapStoresFlow(refreshCategories: true) } } } .onChange(of: addressCacheScope) { _, _ in guard hasRequestedLocation else { return } Task { await bootstrapStoresFlow( forceLocationRefresh: true, category: selectedCategoryQueryValue, refreshCategories: true ) } } .onChange(of: searchText) { _, _ in scheduleSearchIndexUpdate() } .onChange(of: appState.homeFilters.sortOption) { _, _ in scheduleSearchIndexUpdate() } .onChange(of: appState.homeFilters.selectedCategories) { _, _ in scheduleSearchIndexUpdate() } .onChange(of: appState.homeFilters.selectedPriceTier) { _, _ in scheduleSearchIndexUpdate() } .onChange(of: appState.homeFilters.maxDistanceKm) { _, _ in scheduleSearchIndexUpdate() } } private var contentStack: some View { VStack(spacing: 24) { scrollOffsetObserver // The collapsing header is a separate overlay drawn on top of // this ScrollView in the ZStack above, which visually covers // the native pull-to-refresh spinner's position. This gives // refresh feedback that's actually visible, right below the // header, instead of relying on a spinner hidden behind it. if isLoadingStores && stores.isEmpty == false { HStack(spacing: 8) { ProgressView() Text("Atualizando...") .font(AppTypography.caption) .foregroundStyle(AppColors.textMuted) } .frame(maxWidth: .infinity) } categoriesSection section(title: "Featured") { ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: 16) { ForEach(featuredStoresCards) { store in NavigationLink { storeDestination(for: store) } label: { FeaturedStoreCard( store: store, onFavoriteToggle: { Task { await toggleFavoriteStore(storeId: store.id, storeName: store.name) } } ) .frame(width: 190) } .buttonStyle(.plain) } } .padding(.horizontal, 20) } } if appState.featureFlags.isEnabled("at.promo") { section(title: "#PediPromo") { ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: 16) { ForEach(specials) { item in SpecialOfferCard(model: item) .frame(width: 260, height: 120) } } .padding(.horizontal, 20) } } } section(title: "Pertinho de você") { // Once we have stores loaded, keep showing them regardless of // a subsequent refresh's isLoadingStores/storesError state — // a failed or in-flight pull-to-refresh must never hide // already-loaded content. if filteredStoreCards.isEmpty == false { storeCardsList } else if isLoadingStores { HStack { ProgressView() Text("Buscando estabelecimentos próximos...") .font(AppTypography.body) .foregroundStyle(AppColors.textMuted) } .padding(.horizontal, 20) } else if let storesError { VStack(alignment: .leading, spacing: 10) { Text(storesError) .font(AppTypography.body) .foregroundStyle(AppColors.textMuted) Button("Tentar novamente") { Task { await bootstrapStoresFlow( forceLocationRefresh: true, category: selectedCategoryQueryValue, refreshCategories: true ) } } .buttonStyle(.plain) .font(AppTypography.heading3) .foregroundStyle(AppColors.primary) } .padding(.horizontal, 20) } else { Text(emptyResultMessage) .font(AppTypography.body) .foregroundStyle(AppColors.textMuted) .padding(.horizontal, 20) } } } } @ViewBuilder private func storeDestination(for store: FeaturedStoreCardModel) -> some View { StoreDetailView( storeId: store.id, storeName: store.name, storeCoverURL: store.coverURL, storeLogoURL: store.logoURL, storeCategory: store.category, storeRating: store.rating, storeDistance: store.distance, storeDeliveryFee: store.deliveryFee, appState: $appState ) } @ViewBuilder private var storeCardsList: some View { VStack(spacing: 16) { ForEach(filteredStoreCards) { store in NavigationLink { storeDestination(for: store) } label: { FeaturedStoreCard( store: store, onFavoriteToggle: { Task { await toggleFavoriteStore(storeId: store.id, storeName: store.name) } } ) } .buttonStyle(.plain) } } .padding(.horizontal, 20) } private func header(collapseProgress: CGFloat, height: CGFloat) -> some View { let titleOpacity = 1 - clamp(value: collapseProgress * 1.2, lower: 0, upper: 1) let topRowOpacity = 1 - clamp(value: collapseProgress * 1.4, lower: 0, upper: 1) return ZStack(alignment: .top) { RoundedRectangle(cornerRadius: 32, style: .continuous) .fill(AppColors.primary) .frame(height: height) .overlay(headerRings.opacity(1 - collapseProgress), alignment: .topTrailing) VStack(alignment: .leading, spacing: 16) { Spacer().frame(height: 20) HStack(alignment: .center, spacing: 12) { Button { selectedTab = .profile } label: { Circle() .fill(AppColors.brandSoft) .frame(width: 40, height: 40) .overlay { if let profilePictureURL { AsyncStoreImage(imageURL: profilePictureURL) .frame(width: 36, height: 36) .clipShape(Circle()) } else { Image(systemName: "person.fill") .foregroundStyle(AppColors.brandDark) } } } .buttonStyle(.plain) VStack(alignment: .center, spacing: 4) { Text("ENTREGAR EM:") .font(AppTypography.overline) .tracking(AppTypography.captionLetterSpacing) .foregroundStyle(AppColors.brandSoft) .multilineTextAlignment(.center) Button { appState.address.onboardingMessage = nil appState.activeModal = .addressPicker } label: { HStack(spacing: 6) { Text(appState.address.display) .font(AppTypography.heading3) .foregroundStyle(AppColors.textInverse) Image(systemName: "chevron.down") .font(.caption) .foregroundStyle(AppColors.brandSoft) } } .buttonStyle(.plain) } .frame(maxWidth: .infinity, alignment: .center) Circle() .fill(Color.white.opacity(0.18)) .frame(width: 40, height: 40) .overlay( Image(systemName: "bell") .foregroundStyle(AppColors.textInverse) ) } .opacity(topRowOpacity) .offset(y: collapseProgress * -12) if UIDevice().modelName.lowercased().contains("se") || UIDevice().modelName.lowercased().contains("16e") { Text("O que vai querer \npedir hoje?") .font(AppTypography.heading2) .foregroundStyle(AppColors.textInverse) .opacity(titleOpacity) .offset(y: collapseProgress * -20) } else { Text("O que vai querer \npedir hoje?") .font(AppTypography.heading2) .foregroundStyle(AppColors.textInverse) .opacity(titleOpacity) .offset(y: collapseProgress * -20) } SearchBar(placeholder: "Search menu, restaurant or craving", text: $searchText) { appState.homeFilters.availableCategories = categories .filter { $0.id.lowercased() != "all" } .map(\.title) appState.activeModal = .filters } .offset(y: collapseProgress * -120) } .padding(.horizontal, 20) .padding(.top, 18) } } private func section(title: String, @ViewBuilder content: () -> Content) -> some View { VStack(alignment: .leading, spacing: 16) { Text(title) .font(AppTypography.heading2) .foregroundStyle(AppColors.textPrimary) .padding(.horizontal, 20) content() } } private var categoriesSection: some View { VStack(alignment: .leading, spacing: 12) { Text("Categories") .font(AppTypography.heading2) .foregroundStyle(AppColors.textPrimary) .padding(.horizontal, 20) ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: 12) { ForEach(categories) { category in CategoryChip( title: category.title, systemIcon: category.systemIcon, emojiIcon: category.emojiIcon, isActive: category.id == selectedCategory ) .onTapGesture { guard category.id != selectedCategory else { return } selectedCategory = category.id Task { await bootstrapStoresFlow(category: category.id.lowercased() == "all" ? nil : category.title) } } } } .padding(.horizontal, 20) } } } private var headerRings: some View { ZStack { Circle() .stroke(Color.white.opacity(0.08), lineWidth: 1) .frame(width: 180, height: 180) .offset(x: 40, y: -10) Circle() .stroke(Color.white.opacity(0.08), lineWidth: 1) .frame(width: 130, height: 130) .offset(x: 70, y: 10) } } @MainActor private func bootstrapStoresFlow( forceLocationRefresh: Bool = false, forceNetworkRefresh: Bool = false, category: String? = nil, refreshCategories: Bool = false ) async { if isLoadingStores { return } // A refresh (pull-to-refresh) that fails must never wipe the list // the user is already looking at — only a first load with nothing // yet loaded is allowed to show a blocking error state. let hadExistingStores = stores.isEmpty == false isLoadingStores = true if hadExistingStores == false { storesError = nil } // Anonymous browsing has no account address/coordinates — the public // locator uses a manually-picked state/city instead (geolocation is // out of scope for that flow, see public-store-locator-sdd.md). guard appState.session.isAuthenticated else { await loadGuestStores(hadExistingStores: hadExistingStores, category: category, refreshCategories: refreshCategories) return } let coordinate = await resolveCoordinates(forceRefresh: forceLocationRefresh) let hasAddress = hasConfiguredAddress() if coordinate == nil && hasAddress == false { isLoadingStores = false stores = [] storesError = "Defina um endereço para visualizar os estabelecimentos mais próximos." appState.address.onboardingMessage = "Vamos criar um endereço para poder visualizar os estabelecimentos mais próximos de você?" appState.activeModal = .addressPicker return } do { let storesCacheKey = homeStoresCacheKey( lat: coordinate?.0, lng: coordinate?.1, category: category ) if forceLocationRefresh == false, forceNetworkRefresh == false, let cachedStores: [StoreSummary] = AppContentCache.shared.value(for: storesCacheKey, as: [StoreSummary].self) { isLoadingStores = false stores = cachedStores #if os(iOS) for store in cachedStores { printLog( title: "LOGO HOME", msg: "storeId=\(store.id) | storeName=\(store.name) | logoRaw=\(store.logo ?? "nil")" ) } #endif AppContentCache.shared.set(cachedStores, for: AppCacheKey.homeStoresLatestSnapshot, ttl: AppCacheTTL.twoHours) if refreshCategories || (category == nil && categories.count <= 1) { await loadHomeCategories( withFallbackStores: cachedStores, forceRefresh: forceLocationRefresh || forceNetworkRefresh ) if categories.contains(where: { $0.id == selectedCategory }) == false { selectedCategory = "all" } } storesError = nil return } let response = try await ApiService().listStores( lat: coordinate?.0, lng: coordinate?.1, category: category ) isLoadingStores = false if response.error { reportStoresLoadFailure( response.message ?? "Não foi possível carregar os estabelecimentos.", hadExistingStores: hadExistingStores ) return } let results = response.result ?? [] stores = results #if os(iOS) for store in results { printLog( title: "LOGO HOME", msg: "storeId=\(store.id) | storeName=\(store.name) | logoRaw=\(store.logo ?? "nil")" ) } #endif AppContentCache.shared.set(results, for: storesCacheKey, ttl: AppCacheTTL.homeStores) AppContentCache.shared.set(results, for: AppCacheKey.homeStoresLatestSnapshot, ttl: AppCacheTTL.twoHours) if refreshCategories || (category == nil && categories.count <= 1) { await loadHomeCategories( withFallbackStores: results, forceRefresh: forceLocationRefresh || forceNetworkRefresh ) 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) } } func reportStoresLoadFailure(_ message: String, hadExistingStores: Bool) { if hadExistingStores { SnackbarCenter.shared.show(title: message, style: .warning, icon: "exclamationmark.triangle.fill", duration: 3.0) } else { stores = [] storesError = message } } @ViewBuilder private var scrollOffsetObserver: some View { ScrollOffsetObserver { y in // Use only upward displacement for collapse and ignore top bounce. let normalized = max(0, y) scrollOffset = normalized } .frame(width: 0, height: 0) } private var addressCacheScope: String { let selected = appState.address.selectedId ?? "nil" let display = appState.address.display .trimmingCharacters(in: .whitespacesAndNewlines) .lowercased() let lat = appState.address.latitude.map(formatCoordinateScope) ?? "nil" let lng = appState.address.longitude.map(formatCoordinateScope) ?? "nil" return "\(selected)|\(display)|\(lat)|\(lng)" } private func homeStoresCacheKey(lat: Double?, lng: Double?, category: String?) -> String { let normalizedCategory = category?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() ?? "all" let latKey = lat.map(formatCoordinateCache) ?? "nil" let lngKey = lng.map(formatCoordinateCache) ?? "nil" return "stores:\(latKey):\(lngKey):\(normalizedCategory)" } private func formatCoordinateScope(_ value: Double) -> String { String((value * 100_000).rounded() / 100_000) } private func formatCoordinateCache(_ value: Double) -> String { String((value * 10_000).rounded() / 10_000) } private var selectedCategoryQueryValue: String? { guard selectedCategory.lowercased() != "all" else { return nil } guard let selected = categories.first(where: { $0.id == selectedCategory }) else { return nil } return selected.title } func isCancelledRequest(_ error: Error) -> Bool { if error is CancellationError { return true } if let networkError = error as? NetworkError { switch networkError { case .cancelled: return true case .transportError(let message): let normalized = message.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() return normalized.contains("cancel") default: break } } return error.localizedDescription.lowercased().contains("cancel") } }