migration
This commit is contained in:
573
PediFoods/Views/Main/HomeView.swift
Normal file
573
PediFoods/Views/Main/HomeView.swift
Normal file
@@ -0,0 +1,573 @@
|
||||
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 {
|
||||
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<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
|
||||
}
|
||||
|
||||
// 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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user