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? @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 scrollOffsetY: CGFloat = 0 let cardTopInset: CGFloat = 168 let summaryCardBaseHeight: CGFloat = 170 let closedBannerHeight: CGFloat = 44 let coverVisibleUntilY: CGFloat = 253 let storeLogoSize: CGFloat = 84 var body: some View { GeometryReader { geometry in let safeTop = geometry.safeAreaInsets.top ScrollViewReader { proxy in ZStack(alignment: .top) { AppColors.backgroundLight.ignoresSafeArea() ScrollView(showsIndicators: false) { #if os(Android) LazyVStack(spacing: 0) { topSection categoryTabs(proxy: proxy, safeTop: safeTop, isSticky: false) sectionedProducts } #else LazyVStack(spacing: 0, pinnedViews: [.sectionHeaders]) { topSection categoryTabs(proxy: proxy, safeTop: safeTop, isSticky: false) sectionedProducts } #endif } .refreshable { await loadStoreData(forceRefresh: true) } .ignoresSafeArea(edges: .top) .background(ScrollOffsetReader(offsetY: $scrollOffsetY)) } .ignoresSafeArea(edges: .top) .overlay(alignment: .top) { if isCategoryTabsPinned(safeTop: safeTop) { categoryTabs(proxy: proxy, safeTop: safeTop, isSticky: true) .transition(.opacity) .zIndex(20) } } .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() } } private var topSection: some View { ZStack(alignment: .top) { heroSection .frame(height: topSectionHeight) // Hard cut: cover cannot appear below this line. Rectangle() .fill(AppColors.backgroundLight) .frame(height: max(0, topSectionHeight - coverVisibleUntilY)) .offset(y: coverVisibleUntilY) summaryCard .padding(.horizontal, 16) .padding(.top, cardTopInset) storeLogoBadge .padding(.top, cardTopInset - (storeLogoSize / 2)) } .frame(height: topSectionHeight) } private var heroSection: some View { ZStack(alignment: .top) { AsyncStoreImage(imageURL: resolvedURL(storeCoverURL)) .frame(height: topSectionHeight) .overlay( LinearGradient( colors: [Color.black.opacity(0.32), Color.black.opacity(0.05)], startPoint: .top, endPoint: .bottom ) ) VStack(spacing: 0) { HStack { heroIconButton(icon: "chevron.left") { dismiss() } Spacer() heroIconButton(icon: "magnifyingglass") {} heroIconButton(icon: "heart") {} } .padding(.horizontal, 14) .padding(.top, UIDevice.topNotch) Spacer() Text("RESTAURANT") .font(AppTypography.overline) .tracking(1.8) .foregroundStyle(Color.white.opacity(0.92)) .padding(.bottom, 14) } } } private var storeLogoBadge: some View { ZStack { Circle() .fill(AppColors.surface) .frame(width: storeLogoSize, height: storeLogoSize) .overlay( Circle() .stroke(Color.white, lineWidth: 0.1) ) AsyncStoreImage(imageURL: resolvedURL(storeLogoURL)) .frame(width: storeLogoSize - 10, height: storeLogoSize - 10) .clipShape(Circle()) } .shadow(color: Color.black.opacity(0.10), radius: 8, y: 3) } private var summaryCard: some View { VStack(spacing: 0) { VStack(alignment: .leading, spacing: 14) { HStack(alignment: .top) { VStack(alignment: .leading, spacing: 6) { Text(storeName) .font(AppTypography.heading2) .foregroundStyle(AppColors.textPrimary) .lineLimit(2) .padding(.top, 30) Text(storeSubtitle) .font(.caption) .foregroundStyle(AppColors.textMuted) .lineLimit(1) } Spacer() ratingChip } HStack(spacing: 0) { statItem(title: "DISTÂNCIA", value: distanceValueLabel) Divider().frame(height: 34) statItem(title: "TEMPO", value: info?.deliveryTime ?? "25-35 min") Divider().frame(height: 34) statItem(title: "ENTREGA", value: deliveryValueLabel) } .padding(.vertical, 4) } .padding(16) if isStoreOpen == false { Text(closedStoreBannerText) .font(AppTypography.heading3) .foregroundStyle(Color.white) .frame(maxWidth: .infinity, minHeight: closedBannerHeight) .background(AppColors.brandDark) } } .frame(height: summaryCardHeight) .background(AppColors.surface) .clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusXL, style: .continuous)) .shadow(color: AppShadow.soft.color, radius: AppShadow.soft.radius, y: AppShadow.soft.y) } @ViewBuilder private var sectionedProducts: some View { if isLoading { ProgressView() .frame(maxWidth: .infinity, alignment: .center) .padding(.top, 26) } else if let errorMessage { VStack(alignment: .leading, spacing: 10) { Text(errorMessage) .font(AppTypography.body) .foregroundStyle(AppColors.textMuted) Button("Tentar novamente") { Task { await loadStoreData() } } .font(AppTypography.heading3) .foregroundStyle(AppColors.primary) } .padding(.top, 16) } else if categories.isEmpty { Text("Cardápio indisponível no momento.") .font(AppTypography.body) .foregroundStyle(AppColors.textMuted) .padding(.top, 16) .padding(.horizontal, 16) .padding(.bottom, 120) } else { ForEach(categories, id: \.id) { category in Section { VStack(spacing: 12) { ForEach(listItems(for: category)) { item in productCard(item, in: category) } } .padding(.horizontal, 16) .padding(.bottom, 8) } header: { Text(category.name) .font(AppTypography.heading2) .foregroundStyle(AppColors.textPrimary) .id(sectionAnchorId(for: category.id)) .frame(maxWidth: .infinity, alignment: .leading) .padding(.horizontal, 16) .padding(.top, 8) .padding(.bottom, 8) .background(AppColors.backgroundLight) .background( GeometryReader { geometry in Color.clear.preference( key: CategoryHeaderOffsetPreferenceKey.self, value: [category.id: geometry.frame(in: .global).minY] ) } ) } } Color.clear.frame(height: 120) } } private func categoryTabs(proxy: ScrollViewProxy, safeTop: CGFloat, isSticky: Bool) -> some View { ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: 10) { ForEach(categories, id: \.id) { category in let active = selectedCategoryId == category.id Button { selectedCategoryId = category.id isProgrammaticCategoryScroll = true withAnimation(.easeInOut(duration: 0.25)) { proxy.scrollTo(sectionAnchorId(for: category.id), anchor: .top) } DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) { isProgrammaticCategoryScroll = false } } label: { Text(category.name) .font(AppTypography.heading3) .foregroundStyle(active ? AppColors.textInverse : AppColors.textMuted) .padding(.horizontal, 16) .padding(.vertical, 9) .background(active ? AppColors.primary : AppColors.surface) .clipShape(Capsule()) } .buttonStyle(.plain) } } .padding(.horizontal, 16) .padding(.bottom, 10) .padding(.top, isSticky ? categoryTabsPinnedInset(safeTop: safeTop) : 10) } .background(AppColors.backgroundLight) } private func isCategoryTabsPinned(safeTop: CGFloat) -> Bool { scrollOffsetY >= categoryTabsPinThreshold(safeTop: safeTop) } private func categoryTabsPinThreshold(safeTop: CGFloat) -> CGFloat { max(0, topSectionHeight + 10 - safeTop) } private func categoryTabsPinnedInset(safeTop: CGFloat) -> CGFloat { max(8, safeTop - 44) } private func productCard(_ item: StoreCatalogListItem, in category: StoreCatalogCategory) -> some View { let product = item.product let hasSelectableAddons = product.addonGroups.contains { $0.items.isEmpty == false } return HStack(spacing: 12) { VStack(alignment: .leading, spacing: 8) { Text(item.title) .font(AppTypography.heading3) .foregroundStyle(AppColors.textPrimary) .lineLimit(2) .multilineTextAlignment(.leading) if let description = item.description, description.isEmpty == false { Text(description) .font(.caption) .foregroundStyle(AppColors.textMuted) .lineLimit(2) .multilineTextAlignment(.leading) } Text(listPriceLabel(for: product, in: category)) .font(AppTypography.heading3) .foregroundStyle(AppColors.textPrimary) } Spacer() ZStack(alignment: .bottomTrailing) { AsyncStoreImage(imageURL: resolvedURL(item.imageURL)) .frame(width: 92, height: 92) .clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous)) Button { guard isStoreOpen else { SnackbarCenter.shared.show(title: "Loja fechada no momento.", style: .warning, icon: "moon.zzz.fill", duration: 2.0) return } if item.isPizzaSummary, let pizzaCategoryId = item.pizzaCategoryId { requestOpenPizzaSheet(categoryId: pizzaCategoryId) return } if hasSelectableAddons == false { let basePrice = product.price ?? 0 let item = CartItemState( id: "\(storeId)::\(product.id)::base", productId: product.id, storeId: storeId, name: product.name, imageURL: resolvedURL(product.image), quantity: 1, unitPrice: basePrice ) requestAddToCart(item) } else { requestOpenProductSheet(product) } } label: { ZStack(alignment: .leading) { Image(systemName: "plus") .font(.system(size: 16, weight: .bold)) .foregroundStyle(AppColors.textPrimary) .frame(width: 30, height: 30) .background(AppColors.tertiary) .clipShape(Circle()) let qty = quantityInCart(for: item) if qty > 0 { Text("\(qty)") .font(.system(size: 10, weight: .bold)) .foregroundStyle(Color.white) .padding(.horizontal, 5) .padding(.vertical, 2) .background(Color.red) .clipShape(Capsule()) .offset(x: -6, y: -10) .zIndex(1) } } .offset(x: 3, y: -3) .frame(width: 30, height: 30) .appContentShape(Circle()) } .buttonStyle(.plain) .frame(width: 30, height: 30) .disabled(isStoreOpen == false) .opacity(isStoreOpen ? 1 : 0.65) .offset(x: 7, y: 7) } } .padding(12) .background(AppColors.surface) .clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous)) .appContentShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous)) .onTapGesture { guard isStoreOpen else { SnackbarCenter.shared.show(title: "Loja fechada no momento.", style: .warning, icon: "moon.zzz.fill", duration: 2.0) return } if item.isPizzaSummary, let pizzaCategoryId = item.pizzaCategoryId { requestOpenPizzaSheet(categoryId: pizzaCategoryId) return } guard hasSelectableAddons else { return } requestOpenProductSheet(product) } } }