import SwiftUI import LCEssentials #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? @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 didLoad = false @State var categoryHeaderOffsets: [String: CGFloat] = [:] @State var isProgrammaticCategoryScroll = false @State var scrollOffsetY: CGFloat = 0 let cardTopInset: CGFloat = 168 let summaryCardHeight: CGFloat = 170 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) { LazyVStack(spacing: 0) { topSection categoryTabs(proxy: proxy, safeTop: safeTop, isSticky: false) sectionedProducts .padding(.horizontal, 16) .padding(.top, 12) .padding(.bottom, 120) } } .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) } } .overlay(alignment: .topTrailing) { VStack(alignment: .trailing, spacing: 2) { Text("safe \(debugNumber(safeTop))") Text("offset \(debugNumber(scrollOffsetY))") Text("th \(debugNumber(categoryTabsPinThreshold(safeTop: safeTop)))") Text("pin \(isCategoryTabsPinned(safeTop: safeTop) ? 1 : 0)") Text("inset \(debugNumber(categoryTabsPinnedInset(safeTop: safeTop)))") } .font(.system(size: 11, weight: .semibold, design: .monospaced)) .foregroundStyle(.white) .padding(8) .background(Color.black.opacity(0.7)) .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous)) .padding(.top, safeTop + 6) .padding(.trailing, 8) .allowsHitTesting(false) } } } .navigationBarBackButtonHidden(true) .toolbar(.hidden, for: .navigationBar) .toolbarBackground(.hidden, for: .navigationBar) .task { guard didLoad == false else { return } didLoad = true await loadStoreData() } .sheet(item: $selectedProduct) { product in NavigationStack { ProductDetailSheet(product: product, imageURL: resolvedURL(product.image)) } } .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: 5) ) 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(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: "TEMPO", value: info?.deliveryTime ?? "25-35 min") Divider().frame(height: 34) statItem(title: "ENTREGA", value: deliveryValueLabel) } .padding(.vertical, 4) } .padding(16) .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) } else { VStack(alignment: .leading, spacing: 26) { ForEach(categories, id: \.id) { category in VStack(alignment: .leading, spacing: 12) { Text(category.name) .font(AppTypography.heading2) .foregroundStyle(AppColors.textPrimary) .id(sectionAnchorId(for: category.id)) .background( GeometryReader { geometry in Color.clear.preference( key: CategoryHeaderOffsetPreferenceKey.self, value: [category.id: geometry.frame(in: .global).minY] ) } ) VStack(spacing: 12) { ForEach(category.products) { product in productCard(product) } } } } } } } 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(.vertical, 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(20, safeTop + 8) } private func debugNumber(_ value: CGFloat) -> String { if value.isFinite == false { return "inf" } if value > CGFloat(Int.max) { return "max+" } if value < CGFloat(Int.min) { return "min-" } return String(Int(value.rounded())) } private func productCard(_ product: StoreCatalogProduct) -> some View { HStack(spacing: 12) { VStack(alignment: .leading, spacing: 8) { Text(product.name) .font(AppTypography.heading3) .foregroundStyle(AppColors.textPrimary) .lineLimit(2) .multilineTextAlignment(.leading) if let description = product.description, description.isEmpty == false { Text(description) .font(.caption) .foregroundStyle(AppColors.textMuted) .lineLimit(2) .multilineTextAlignment(.leading) } Text(formatCurrency(product.price)) .font(AppTypography.heading3) .foregroundStyle(AppColors.textPrimary) } Spacer() ZStack(alignment: .bottomTrailing) { AsyncStoreImage(imageURL: resolvedURL(product.image)) .frame(width: 92, height: 92) .clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous)) Button { selectedProduct = product } label: { Image(systemName: "plus") .font(.system(size: 16, weight: .bold)) .foregroundStyle(AppColors.textPrimary) .frame(width: 30, height: 30) .background(AppColors.tertiary) .clipShape(Circle()) } .buttonStyle(.plain) .offset(x: 7, y: 7) } } .padding(12) .background(AppColors.surface) .clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous)) } private func heroIconButton(icon: String, action: @escaping () -> Void) -> some View { Button(action: action) { Image(systemName: icon) .font(.system(size: 14, weight: .semibold)) .foregroundStyle(Color.white) .frame(width: 32, height: 32) .background(Color.white.opacity(0.24)) .clipShape(Circle()) } .buttonStyle(.plain) } private func statItem(title: String, value: String) -> some View { VStack(spacing: 4) { Text(title) .font(AppTypography.caption) .foregroundStyle(AppColors.textMuted) Text(value) .font(AppTypography.heading3) .foregroundStyle(AppColors.textPrimary) } .frame(maxWidth: .infinity) } private var ratingChip: some View { HStack(spacing: 6) { Image(systemName: "star.fill") .font(.caption) .foregroundStyle(Color(hex: "#F5B335")) Text(String(format: "%.1f", storeRating ?? 0)) .font(AppTypography.caption) .foregroundStyle(AppColors.textPrimary) } .padding(.horizontal, 10) .padding(.vertical, 6) .background(AppColors.brandSoft) .clipShape(Capsule()) } private var storeSubtitle: String { let category = (storeCategory ?? "").trimmingCharacters(in: .whitespacesAndNewlines) if category.isEmpty { return "Restaurant" } return category } private var deliveryValueLabel: String { if let minOrder = info?.minOrder { return formatCurrency(minOrder) } if let storeDistance, storeDistance.isEmpty == false { return storeDistance } return "R$ --" } @MainActor private func loadStoreData() async { isLoading = true errorMessage = nil do { async let infoRequest = ApiService().storeInfo(storeId: storeId) async let catalogRequest = ApiService().storeCatalog(storeId: storeId) let (infoResponse, catalogResponse) = try await (infoRequest, catalogRequest) if infoResponse.error { errorMessage = infoResponse.message ?? "Não foi possível carregar a loja." isLoading = false return } if catalogResponse.error { errorMessage = catalogResponse.message ?? "Não foi possível carregar o catálogo." isLoading = false return } info = infoResponse.result categories = catalogResponse.result ?? [] selectedCategoryId = categories.first?.id isLoading = false } catch { isLoading = false if let network = error as? NetworkError { errorMessage = network.errorDescription ?? "Erro ao carregar loja." return } if let service = error as? ApiServiceError { errorMessage = service.errorDescription ?? "Erro ao carregar loja." return } errorMessage = "Erro ao carregar loja." } } private func resolvedURL(_ raw: String?) -> String? { guard let raw = raw?.trimmingCharacters(in: .whitespacesAndNewlines), raw.isEmpty == false else { return nil } let lower = raw.lowercased() if lower.hasPrefix("http://") || lower.hasPrefix("https://") { return raw } let base = ApiConfig.baseURL.absoluteString.trimmingCharacters(in: CharacterSet(charactersIn: "/")) let path = raw.hasPrefix("/") ? raw : "/\(raw)" return "\(base)\(path)" } private func formatCurrency(_ value: Double?) -> String { guard let value else { return "R$ --" } return String(format: "R$ %.2f", value).replacingOccurrences(of: ".", with: ",") } private var topSectionHeight: CGFloat { cardTopInset + summaryCardHeight } private func sectionAnchorId(for categoryId: String) -> String { "category-section-\(categoryId)" } private func syncCategoryWithScroll() { guard isLoading == false else { return } guard isProgrammaticCategoryScroll == false else { return } guard categoryHeaderOffsets.isEmpty == false else { return } // Section whose header is nearest to the top content area wins. let topThreshold: CGFloat = 180 let sorted = categoryHeaderOffsets.sorted { $0.value < $1.value } if let current = sorted.last(where: { $0.value <= topThreshold })?.key { selectedCategoryId = current return } if let firstVisible = sorted.first?.key { selectedCategoryId = firstVisible } } } struct CategoryHeaderOffsetPreferenceKey: PreferenceKey { static let defaultValue: [String: CGFloat] = [:] static func reduce(value: inout [String: CGFloat], nextValue: () -> [String: CGFloat]) { value.merge(nextValue(), uniquingKeysWith: { _, new in new }) } } struct ScrollOffsetReader: View { @Binding var offsetY: CGFloat var body: some View { #if canImport(UIKit) ScrollOffsetReaderRepresentable(offsetY: $offsetY) #else Color.clear #endif } } #if canImport(UIKit) struct ScrollOffsetReaderRepresentable: UIViewRepresentable { @Binding var offsetY: CGFloat func makeUIView(context: Context) -> OffsetProbeView { let view = OffsetProbeView() view.onOffsetChanged = { value in if offsetY != value { offsetY = value } } return view } func updateUIView(_ uiView: OffsetProbeView, context: Context) { uiView.onOffsetChanged = { value in if offsetY != value { offsetY = value } } } } final class OffsetProbeView: UIView { var onOffsetChanged: ((CGFloat) -> Void)? private var observation: NSKeyValueObservation? private weak var observedScrollView: UIScrollView? override func didMoveToWindow() { super.didMoveToWindow() attachIfNeeded() } override func didMoveToSuperview() { super.didMoveToSuperview() attachIfNeeded() } private func attachIfNeeded() { guard observation == nil else { return } guard observedScrollView == nil else { return } if let scrollView = enclosingScrollView() ?? findScrollViewInWindow() { observe(scrollView) return } retryAttach() } private func observe(_ scrollView: UIScrollView) { observedScrollView = scrollView observation = scrollView.observe(\.contentOffset, options: [.new, .initial]) { [weak self, weak scrollView] _, change in guard let self, let scrollView, let y = change.newValue?.y else { return } let adjusted = max(0, y + scrollView.adjustedContentInset.top) DispatchQueue.main.async { self.onOffsetChanged?(adjusted) } } } private func retryAttach() { DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { [weak self] in self?.attachIfNeeded() } } private func enclosingScrollView() -> UIScrollView? { var current: UIView? = self while let view = current { if let scrollView = view as? UIScrollView { return scrollView } current = view.superview } return nil } private func findScrollViewInWindow() -> UIScrollView? { guard let window else { return nil } let targetPoint = convert(CGPoint(x: bounds.midX, y: bounds.midY), to: window) return findScrollView(in: window, containing: targetPoint) } private func findScrollView(in root: UIView, containing point: CGPoint) -> UIScrollView? { for subview in root.subviews.reversed() { if let match = findScrollView(in: subview, containing: point) { return match } } if let scrollView = root as? UIScrollView { let rectInWindow = scrollView.convert(scrollView.bounds, to: window) if rectInWindow.contains(point) { return scrollView } } return nil } deinit { observation?.invalidate() } } #endif struct AsyncStoreImage: View { let imageURL: String? var body: some View { Group { if let imageURL, let url = URL(string: imageURL) { AsyncImage(url: url) { phase in switch phase { case .success(let image): image .resizable() .scaledToFill() default: fallback } } } else { fallback } } .frame(maxWidth: .infinity, maxHeight: .infinity) .background(AppColors.brandSoft) .clipped() } private var fallback: some View { Image("placeholder-product") .resizable() .scaledToFill() } } struct ProductDetailSheet: View { let product: StoreCatalogProduct let imageURL: String? @Environment(\.dismiss) var dismiss var body: some View { ScrollView(showsIndicators: false) { VStack(alignment: .leading, spacing: 16) { AsyncStoreImage(imageURL: imageURL) .frame(height: 220) .clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusXL, style: .continuous)) Text(product.name) .font(AppTypography.heading2) .foregroundStyle(AppColors.textPrimary) if let description = product.description, description.isEmpty == false { Text(description) .font(AppTypography.body) .foregroundStyle(AppColors.textMuted) } Text(String(format: "R$ %.2f", product.price ?? 0).replacingOccurrences(of: ".", with: ",")) .font(AppTypography.heading2) .foregroundStyle(AppColors.primary) if product.addonGroups.isEmpty == false { Text("Adicionais") .font(AppTypography.heading3) .foregroundStyle(AppColors.textPrimary) ForEach(product.addonGroups) { group in VStack(alignment: .leading, spacing: 8) { Text(group.name) .font(AppTypography.heading3) .foregroundStyle(AppColors.textPrimary) ForEach(group.items) { item in HStack { Text(item.name) .font(AppTypography.body) .foregroundStyle(AppColors.textMuted) Spacer() Text(String(format: "+ R$ %.2f", item.price ?? 0).replacingOccurrences(of: ".", with: ",")) .font(AppTypography.body) .foregroundStyle(AppColors.textPrimary) } } } .padding(12) .background(AppColors.surface) .clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous)) } } } .padding(20) .padding(.bottom, 80) } .safeAreaInset(edge: .bottom) { HStack(spacing: 12) { Button(action: {}) { Image(systemName: "bag") .font(.system(size: 16, weight: .bold)) .foregroundStyle(AppColors.textPrimary) .frame(width: 48, height: 48) .background(AppColors.surface) .clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous)) } .buttonStyle(.plain) PrimaryButton(title: "Comprar agora", action: {}) } .padding(.horizontal, 20) .padding(.top, 8) .padding(.bottom, 12) .background(.ultraThinMaterial) } .background(AppColors.backgroundLight.ignoresSafeArea()) .navigationTitle("Detalhes") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .topBarTrailing) { Button("Fechar") { dismiss() } .foregroundStyle(AppColors.primary) } } } }