import Foundation 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 collapseBaseOffset: CGFloat = 0 @State var collapseDragStartOffset: CGFloat? = nil @State var hasRequestedLocation = false @State var isLoadingStores = false @State var storesError: String? = nil @State var stores: [StoreSummary] = [] 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 = 18 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 { await bootstrapStoresFlow( forceLocationRefresh: true, category: selectedCategory == "all" ? nil : selectedCategory, refreshCategories: true ) } .background(scrollOffsetObserver) .simultaneousGesture( DragGesture(minimumDistance: 0) .onChanged { value in let range = headerExpandedHeight - headerCollapsedHeight if collapseDragStartOffset == nil { collapseDragStartOffset = collapseBaseOffset } let start = collapseDragStartOffset ?? collapseBaseOffset let candidate = start - value.translation.height scrollOffset = clamp(value: candidate, lower: 0, upper: range) } .onEnded { _ in collapseBaseOffset = scrollOffset collapseDragStartOffset = nil } ) header(collapseProgress: collapseProgress, height: headerHeight) .frame(maxWidth: .infinity, alignment: .top) } .background(AppColors.backgroundLight) .ignoresSafeArea(edges: .top) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) .onAppear { if hasRequestedLocation == false { hasRequestedLocation = true Task { await bootstrapStoresFlow(refreshCategories: true) } } collapseBaseOffset = scrollOffset } .onChange(of: addressCacheScope) { _, _ in guard hasRequestedLocation else { return } Task { await bootstrapStoresFlow( forceLocationRefresh: true, category: selectedCategory == "all" ? nil : selectedCategory, refreshCategories: true ) } } } private var contentStack: some View { VStack(spacing: 24) { categoriesSection section(title: "Featured") { ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: 16) { ForEach(featuredStoresCards) { store in NavigationLink { StoreDetailView( storeId: store.id, storeName: store.name, storeCoverURL: store.coverURL, storeLogoURL: store.logoURL, storeCategory: store.category, storeRating: store.rating, storeDistance: store.distance, appState: $appState ) } label: { FeaturedStoreCard(store: store) .frame(width: 190) } .buttonStyle(.plain) } } .padding(.horizontal, 20) } } 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ê") { 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: selectedCategory == "all" ? nil : selectedCategory, refreshCategories: true ) } } .font(AppTypography.heading3) .foregroundStyle(AppColors.primary) } .padding(.horizontal, 20) } else if nearbyStoreCards.isEmpty { Text("Nenhum estabelecimento encontrado próximo à sua localização.") .font(AppTypography.body) .foregroundStyle(AppColors.textMuted) .padding(.horizontal, 20) } else { VStack(spacing: 16) { ForEach(nearbyStoreCards) { store in NavigationLink { StoreDetailView( storeId: store.id, storeName: store.name, storeCoverURL: store.coverURL, storeLogoURL: store.logoURL, storeCategory: store.category, storeRating: store.rating, storeDistance: store.distance, appState: $appState ) } label: { FeaturedStoreCard(store: store) } .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.brandDark) .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 pedir hoje?\n ") .font(AppTypography.heading2) .foregroundStyle(AppColors.textInverse) .opacity(titleOpacity) .offset(y: collapseProgress * -20) } SearchBar(placeholder: "Search menu, restaurant or craving", text: $searchText) { 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 == "all" ? nil : category.id) } } } } .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) } } private var storesByPositiveReviews: [StoreSummary] { stores.sorted { lhs, rhs in let lhsPositive = lhs.positiveReviews ?? lhs.reviewsCount ?? 0 let rhsPositive = rhs.positiveReviews ?? rhs.reviewsCount ?? 0 if lhsPositive != rhsPositive { return lhsPositive > rhsPositive } let lhsRating = lhs.rating ?? 0 let rhsRating = rhs.rating ?? 0 if lhsRating != rhsRating { return lhsRating > rhsRating } return (lhs.distance ?? .greatestFiniteMagnitude) < (rhs.distance ?? .greatestFiniteMagnitude) } } private var featuredStoresCards: [FeaturedStoreCardModel] { Array(storesByPositiveReviews.prefix(5)).map(mapStoreToCard) } private var nearbyStoreCards: [FeaturedStoreCardModel] { let featuredIds = Set(storesByPositiveReviews.prefix(5).map(\.id)) let remaining = storesByPositiveReviews.filter { featuredIds.contains($0.id) == false } return Array(remaining.prefix(20)).map(mapStoreToCard) } private func mapStoreToCard(_ store: StoreSummary) -> FeaturedStoreCardModel { let coverURL = resolveStoreMediaURL(store.cover) let logoURL = resolveStoreMediaURL(store.logo) return FeaturedStoreCardModel( id: store.id, name: store.name, rating: store.rating ?? 0, reviews: String(store.positiveReviews ?? store.reviewsCount ?? 0), distance: formatDistance(store.distance), category: store.category ?? "Loja", promoText: nil, isFavorite: appState.favorites.storeIds.contains(store.id), iconName: "storefront", imageURL: coverURL ?? logoURL, logoURL: logoURL, coverURL: coverURL, isOpen: store.isOpen ?? true, statusLabel: store.statusLabel ) } private func resolveStoreMediaURL(_ raw: String?) -> String? { ImageSourceResolver.resolve(raw) } private var profilePictureURL: String? { resolveStoreMediaURL(appState.profile.profilePicture) } private func formatDistance(_ distance: Double?) -> String { guard let distance else { return "Distância indisponível" } if distance >= 1 { return String(format: "%.1f km", distance) } return "\(Int(distance * 1000)) m" } @MainActor private func bootstrapStoresFlow( forceLocationRefresh: Bool = false, category: String? = nil, refreshCategories: Bool = false ) async { isLoadingStores = true storesError = nil 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, 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) 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 { stores = [] storesError = response.message ?? "Não foi possível carregar os estabelecimentos." 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) if categories.contains(where: { $0.id == selectedCategory }) == false { selectedCategory = "all" } } storesError = nil } catch { isLoadingStores = false stores = [] storesError = storesUserMessage(error) } } @ViewBuilder private var scrollOffsetObserver: some View { #if os(iOS) ScrollOffsetObserver { y in // Use only upward displacement for collapse and ignore top bounce. let normalized = max(0, y) scrollOffset = normalized collapseBaseOffset = normalized } .frame(width: 0, height: 0) #else EmptyView() #endif } 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 { String(format: "%.5f", $0) } ?? "nil" let lng = appState.address.longitude.map { String(format: "%.5f", $0) } ?? "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 { String(format: "%.4f", $0) } ?? "nil" let lngKey = lng.map { String(format: "%.4f", $0) } ?? "nil" return "stores:\(latKey):\(lngKey):\(normalizedCategory)" } }