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,205 @@
import SwiftUI
#if canImport(LCEssentials) && os(iOS)
import LCEssentials
#endif
#if canImport(UIKit)
import UIKit
#endif
struct StoreDetailView: View {
let storeId: String
let storeName: String
let storeCoverURL: String?
let storeLogoURL: String?
let storeCategory: String?
let storeRating: Double?
let storeDistance: String?
let storeDeliveryFee: Double?
@Binding var appState: AppState
@Environment(\.dismiss) var dismiss
@State var isLoading = true
@State var errorMessage: String? = nil
@State var info: StoreInfoResult? = nil
@State var categories: [StoreCatalogCategory] = []
@State var selectedCategoryId: String? = nil
@State var selectedProduct: StoreCatalogProduct? = nil
@State var selectedPizzaCategoryId: String? = nil
@State var showSwitchStoreAlert = false
@State var pendingCartItem: CartItemState? = nil
@State var pendingProductSheet: StoreCatalogProduct? = nil
@State var pendingPizzaCategoryId: String? = nil
@State var pendingCartAction: CartAction = .add
@State var didLoad = false
@State var categoryHeaderOffsets: [String: CGFloat] = [:]
@State var isProgrammaticCategoryScroll = false
@State var isFavoriteRequestInFlight = false
@State var scrollOffset: CGFloat = 0
var isCategoryTabsPinned: Bool { scrollOffset >= topSectionHeight }
var stretchAmount: CGFloat { max(0, -scrollOffset) }
let cardTopInset: CGFloat = 180
let summaryCardBaseHeight: CGFloat = 212
let closedBannerHeight: CGFloat = 44
let coverVisibleUntilY: CGFloat = 253
let storeLogoSize: CGFloat = 84
var safeAreaTop: CGFloat {
#if canImport(UIKit)
return UIDevice.appSafeAreaTop
#else
return 0
#endif
}
var body: some View {
ScrollViewReader { proxy in
ZStack(alignment: .top) {
ScrollView(showsIndicators: false) {
ScrollOffsetReader(offsetY: $scrollOffset)
LazyVStack(spacing: 0) {
topSection
// Guaranteed-visible refresh feedback, right below
// the hero not relying on the native spinner's
// position (unreliable here, see .refreshable note
// below).
if isLoading && categories.isEmpty == false {
HStack(spacing: 8) {
ProgressView()
Text("Atualizando...")
.font(AppTypography.caption)
.foregroundStyle(AppColors.textMuted)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 10)
}
categoryTabs(proxy: proxy, isPinned: false)
.opacity(isCategoryTabsPinned ? 0 : 1)
sectionedProducts
}
}
.coordinateSpace(name: StoreDetailScrollCoordinateSpace.name)
.refreshable {
// Run the actual load in its own unstructured Task and
// await that, instead of awaiting loadStoreData directly
// in this closure. SwiftUI can cancel .refreshable's own
// wrapping Task (e.g. the gesture not fully "committing")
// independent of whether the network call is still
// legitimately in flight. Awaiting Task.value here
// blocks until the detached load genuinely finishes
// (success, error, or our own 20s ApiClient timeout),
// so a premature refreshable-cancellation can no longer
// silently swallow a real in-flight request.
await Task { await loadStoreData(forceRefresh: true) }.value
}
// NOT .ignoresSafeArea here: combined with .refreshable on
// the same view, it breaks the native pull-to-refresh
// spinner's positioning (renders invisible/off-place) even
// though the gesture still fires the closure. The hero
// image and gradient above already bleed under the status
// bar independently via their own .ignoresSafeArea calls.
.background(AppColors.backgroundLight)
categoryTabs(proxy: proxy, isPinned: true)
.opacity(isCategoryTabsPinned ? 1 : 0)
.allowsHitTesting(isCategoryTabsPinned)
.zIndex(10)
}
.ignoresSafeArea(edges: .top)
.saturation(isStoreOpen ? 1 : 0)
}
.navigationBarBackButtonHidden(true)
.appHiddenNavigationBar()
.task {
guard didLoad == false else { return }
didLoad = true
await loadStoreData(forceRefresh: false)
}
.sheet(item: $selectedProduct) { product in
NavigationStack {
ProductDetailSheet(
product: product,
imageURL: resolvedURL(product.image),
storeId: storeId,
currentQuantityForItemId: { itemId in
currentQuantity(forCartItemId: itemId)
},
onAdd: { item in
guard isStoreOpen else {
SnackbarCenter.shared.show(title: "Loja fechada no momento.", style: .warning, icon: "moon.zzz.fill", duration: 2.0)
return
}
requestSetCartItem(item)
}
)
}
}
.sheet(
isPresented: Binding(
get: { selectedPizzaCategoryId != nil },
set: { isPresented in
if isPresented == false {
selectedPizzaCategoryId = nil
}
}
)
) {
if let category = categories.first(where: { $0.id == selectedPizzaCategoryId }) {
NavigationStack {
PizzaProductDetailSheet(
category: category,
storeId: storeId,
resolveImageURL: { raw in resolvedURL(raw) },
currentQuantityForItemId: { itemId in
currentQuantity(forCartItemId: itemId)
},
onAdd: { item in
guard isStoreOpen else {
SnackbarCenter.shared.show(title: "Loja fechada no momento.", style: .warning, icon: "moon.zzz.fill", duration: 2.0)
return
}
requestSetCartItem(item)
}
)
}
} else {
ProgressView()
}
}
.alert("Trocar de loja?", isPresented: $showSwitchStoreAlert) {
Button("Cancelar", role: .cancel) {
pendingCartItem = nil
pendingProductSheet = nil
pendingPizzaCategoryId = nil
}
Button("Limpar carrinho e adicionar", role: .destructive) {
appState.cart.clear()
switch pendingCartAction {
case .add:
guard let pendingCartItem else { return }
applyAddToCart(pendingCartItem)
case .set:
guard let pendingCartItem else { return }
applySetCartItem(pendingCartItem)
case .openProductSheet:
guard let pendingProductSheet else { return }
selectedProduct = pendingProductSheet
case .openPizzaSheet:
guard let pendingPizzaCategoryId else { return }
selectedPizzaCategoryId = pendingPizzaCategoryId
}
self.pendingCartItem = nil
self.pendingProductSheet = nil
self.pendingPizzaCategoryId = nil
}
} message: {
Text("Seu carrinho tem itens de outra loja. Deseja limpar o carrinho atual para adicionar este produto?")
}
.onPreferenceChange(CategoryHeaderOffsetPreferenceKey.self) { offsets in
categoryHeaderOffsets = offsets
syncCategoryWithScroll()
}
}
}