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() } } }