This commit is contained in:
Daniel Arantes Loverde
2026-07-07 15:11:31 -03:00
parent 8b9ebdcb44
commit e51c99973f
293 changed files with 457 additions and 4919 deletions

View File

@@ -0,0 +1,570 @@
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<String> = []
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 {
// 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 {
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
)
} 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 var storeCardsList: some View {
VStack(spacing: 16) {
ForEach(filteredStoreCards) { 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,
storeDeliveryFee: store.deliveryFee,
appState: $appState
)
} 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<Content: View>(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
}
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)
}
}
private 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
}
private 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")
}
}