import SwiftUI #if os(iOS) import UIKit #endif struct HomeView: View { @Binding var appState: AppState @State var searchText = "" @State var selectedCategory = "Stores" @State var scrollOffset: CGFloat = 0 @State var hasRequestedLocation = false @State var isLoadingStores = false @State var storesError: String? = nil @State var stores: [StoreSummary] = [] private let categories: [CategoryModel] = [ .init(title: "Stores", systemIcon: "storefront"), .init(title: "Asian", systemIcon: "fork.knife"), .init(title: "Breakfast", systemIcon: "sun.max"), .init(title: "Pizza", systemIcon: "takeoutbag.and.cup.and.straw"), .init(title: "Dessert", systemIcon: "cup.and.saucer") ] private let specials: [SpecialOfferCardModel] = [ .init(title: "Get 50% OFF", subtitle: "For your first order", colors: [Color(hex: "#1E6B43"), Color(hex: "#58A56C")]), .init(title: "Enjoy spicy day", subtitle: "Delivery in 20 min", colors: [Color(hex: "#F04B3E"), Color(hex: "#FF8C42")]) ] private let headerExpandedHeight: CGFloat = 260 private let headerCollapsedHeight: CGFloat = 120 private let contentTopPadding: CGFloat = 296 var body: some View { let collapseProgress = clamp(value: -scrollOffset / (headerExpandedHeight - headerCollapsedHeight), lower: 0, upper: 1) let headerHeight = headerExpandedHeight - (headerExpandedHeight - headerCollapsedHeight) * collapseProgress let headerPadding = headerExpandedHeight - (headerExpandedHeight - headerCollapsedHeight) * collapseProgress return ZStack(alignment: .top) { #if os(iOS) TrackableScrollView(onOffsetChange: { value in scrollOffset = min(0, -value) }) { contentStack .padding(.top, headerPadding - 10) .padding(.bottom, 24) } .frame(maxWidth: .infinity, maxHeight: .infinity) #else ScrollView { contentStack .padding(.top, headerPadding + 16) .padding(.bottom, 24) } #endif 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() } } } } private var contentStack: some View { VStack(spacing: 24) { scrollOffsetMarker categoriesSection section(title: "Featured") { ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: 16) { ForEach(featuredStoresCards) { store in FeaturedStoreCard(store: store) .frame(width: 190) } } .padding(.horizontal, 20) } } section(title: "#SpecialForYou") { ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: 16) { ForEach(specials) { item in SpecialOfferCard(model: item) .frame(width: 260, height: 120) } } .padding(.horizontal, 20) } } section(title: "Near you") { 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) } } .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 FeaturedStoreCard(store: store) } } .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) { Circle() .fill(AppColors.brandSoft) .frame(width: 40, height: 40) .overlay( Image(systemName: "person.fill") .foregroundStyle(AppColors.brandDark) ) VStack(alignment: .leading, spacing: 4) { Text("DELIVERY LOCATION") .font(AppTypography.overline) .tracking(AppTypography.captionLetterSpacing) .foregroundStyle(AppColors.brandSoft) Button { 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) } Spacer() 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) Text("O que vai querer pedir hoje?") .font(AppTypography.heading1) .foregroundStyle(AppColors.textInverse) .opacity(titleOpacity) .offset(y: collapseProgress * -20) SearchBar(placeholder: "Search menu, restaurant or craving", text: $searchText) { appState.activeModal = .filters } .offset(y: collapseProgress * -140) } .padding(.horizontal, 20) .padding(.top, 18) } } @ViewBuilder private var scrollOffsetMarker: some View { #if os(iOS) EmptyView() #else ScrollOffsetReader() .offset(y: -contentTopPadding) #endif } 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, isActive: category.title == selectedCategory ) .onTapGesture { selectedCategory = 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) } } private var sortedStores: [StoreSummary] { stores.sorted { lhs, rhs in (lhs.distance ?? .greatestFiniteMagnitude) < (rhs.distance ?? .greatestFiniteMagnitude) } } private var featuredStoresCards: [FeaturedStoreCardModel] { Array(sortedStores.prefix(6)).map(mapStoreToCard) } private var nearbyStoreCards: [FeaturedStoreCardModel] { Array(sortedStores.prefix(20)).map(mapStoreToCard) } private func mapStoreToCard(_ store: StoreSummary) -> FeaturedStoreCardModel { FeaturedStoreCardModel( name: store.name, rating: store.rating ?? 0, reviews: "0", distance: formatDistance(store.distance), category: store.category ?? "Loja", promoText: nil, isFavorite: appState.favorites.storeIds.contains(store.id), iconName: "storefront" ) } 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) async { isLoadingStores = true storesError = nil guard let coordinate = await resolveCoordinates(forceRefresh: forceLocationRefresh) else { 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 response = try await ApiService().listStores(lat: coordinate.0, lng: coordinate.1) isLoadingStores = false if response.error { stores = [] storesError = response.message ?? "Não foi possível carregar os estabelecimentos." return } stores = response.result ?? [] storesError = nil } catch { isLoadingStores = false stores = [] storesError = storesUserMessage(error) } } @MainActor private func resolveCoordinates(forceRefresh: Bool) async -> (Double, Double)? { if !forceRefresh { if let lat = appState.address.latitude, let lng = appState.address.longitude { return (lat, lng) } if let cached = LocationService.shared.cachedLocation() { appState.address.latitude = cached.0 appState.address.longitude = cached.1 return cached } if hasConfiguredAddress() == false { return nil } } let coordinate = await LocationService.shared.requestLocationAsync(timeoutSeconds: 3) if let coordinate { appState.address.latitude = coordinate.0 appState.address.longitude = coordinate.1 } return coordinate } private func hasConfiguredAddress() -> Bool { if appState.address.selectedId != nil { return true } if appState.address.latitude != nil, appState.address.longitude != nil { return true } let normalized = appState.address.display .trimmingCharacters(in: .whitespacesAndNewlines) .lowercased() return normalized.isEmpty == false && normalized != "defina seu endereco" } private 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." } } struct CategoryModel: Identifiable { let id = UUID() let title: String let systemIcon: String } struct CategoryChip: View { let title: String let systemIcon: String let isActive: Bool var body: some View { HStack(spacing: 8) { Image(systemName: systemIcon) .font(.caption) Text(title) .font(AppTypography.heading3) } .foregroundStyle(isActive ? AppColors.textInverse : AppColors.textPrimary) .padding(.horizontal, 16) .padding(.vertical, 10) .background(isActive ? AppColors.brandDark : AppColors.surface) .clipShape(Capsule()) } } struct SearchBar: View { let placeholder: String @Binding var text: String var onFilterTap: () -> Void = {} var body: some View { HStack(spacing: 12) { Image(systemName: "magnifyingglass") .foregroundStyle(AppColors.textMuted) TextField(placeholder, text: $text) .appNoAutoCap() Spacer() Button(action: onFilterTap) { Image(systemName: "slider.horizontal.3") .foregroundStyle(AppColors.textMuted) } } .padding(.horizontal, 16) .frame(height: 52) .background(AppColors.surface) .clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous)) } } struct ScrollOffsetKey: PreferenceKey { static let defaultValue: CGFloat = 0 static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { value = nextValue() } } struct ScrollOffsetReader: View { var body: some View { GeometryReader { proxy in Color.clear .preference(key: ScrollOffsetKey.self, value: proxy.frame(in: .named("scroll")).minY) } .frame(height: 1) } } func clamp(value: CGFloat, lower: CGFloat, upper: CGFloat) -> CGFloat { min(max(value, lower), upper) } #if os(iOS) @MainActor struct TrackableScrollView: UIViewRepresentable { let onOffsetChange: (CGFloat) -> Void let content: Content init(onOffsetChange: @escaping (CGFloat) -> Void, @ViewBuilder content: () -> Content) { self.onOffsetChange = onOffsetChange self.content = content() } func makeCoordinator() -> Coordinator { Coordinator(onOffsetChange: onOffsetChange) } func makeUIView(context: Context) -> UIScrollView { let scrollView = UIScrollView() scrollView.showsVerticalScrollIndicator = false scrollView.alwaysBounceVertical = true let host = UIHostingController(rootView: content) host.view.translatesAutoresizingMaskIntoConstraints = false host.view.backgroundColor = .clear scrollView.addSubview(host.view) NSLayoutConstraint.activate([ host.view.leadingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.leadingAnchor), host.view.trailingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.trailingAnchor), host.view.topAnchor.constraint(equalTo: scrollView.contentLayoutGuide.topAnchor), host.view.bottomAnchor.constraint(equalTo: scrollView.contentLayoutGuide.bottomAnchor), host.view.widthAnchor.constraint(equalTo: scrollView.frameLayoutGuide.widthAnchor) ]) context.coordinator.hostingController = host scrollView.delegate = context.coordinator return scrollView } func updateUIView(_ uiView: UIScrollView, context: Context) { context.coordinator.hostingController?.rootView = content } final class Coordinator: NSObject, UIScrollViewDelegate { var hostingController: UIHostingController? let onOffsetChange: (CGFloat) -> Void init(onOffsetChange: @escaping (CGFloat) -> Void) { self.onOffsetChange = onOffsetChange } func scrollViewDidScroll(_ scrollView: UIScrollView) { onOffsetChange(scrollView.contentOffset.y) } } } #endif