From b027a10c8d6240dc6c19606705b6a78bbd984ccf Mon Sep 17 00:00:00 2001 From: Daniel Arantes Loverde Date: Thu, 12 Feb 2026 15:05:09 -0300 Subject: [PATCH] scroll stick --- .../PediFoods/Components/StoreCard.swift | 5 +- .../PediFoods/Resources/Localizable.xcstrings | 47 ++ .../PediFoods/Services/ApiService.swift | 201 +++++ .../PediFoods/Views/Main/HomeView.swift | 49 +- .../Views/Main/StoreDetailView.swift | 755 ++++++++++++++++++ 5 files changed, 1044 insertions(+), 13 deletions(-) create mode 100644 pedi-foods/Sources/PediFoods/Views/Main/StoreDetailView.swift diff --git a/pedi-foods/Sources/PediFoods/Components/StoreCard.swift b/pedi-foods/Sources/PediFoods/Components/StoreCard.swift index d3a6b0d..8c64920 100644 --- a/pedi-foods/Sources/PediFoods/Components/StoreCard.swift +++ b/pedi-foods/Sources/PediFoods/Components/StoreCard.swift @@ -32,11 +32,10 @@ struct FeaturedStoreCard: View { .padding(10) } } - + Text(store.name) .font(AppTypography.heading3) .foregroundStyle(AppColors.textPrimary) - .lineLimit(1) HStack(spacing: 6) { Image(systemName: "star.fill") @@ -129,6 +128,8 @@ struct FeaturedStoreCardModel: Identifiable { let isFavorite: Bool let iconName: String let imageURL: String? + let logoURL: String? + let coverURL: String? } struct SpecialOfferCard: View { diff --git a/pedi-foods/Sources/PediFoods/Resources/Localizable.xcstrings b/pedi-foods/Sources/PediFoods/Resources/Localizable.xcstrings index 38eba85..9bce5b8 100644 --- a/pedi-foods/Sources/PediFoods/Resources/Localizable.xcstrings +++ b/pedi-foods/Sources/PediFoods/Resources/Localizable.xcstrings @@ -72,6 +72,10 @@ } } }, + "Adicionais" : { + "comment" : "A heading for the additional options available for a product.", + "isCommentAutoGenerated" : true + }, "Adicionar novo endereço" : { "comment" : "A button label that translates to \"Add new address\" in English.", "isCommentAutoGenerated" : true @@ -156,6 +160,10 @@ } } }, + "Cardápio indisponível no momento." : { + "comment" : "A message displayed when a store's menu is unavailable.", + "isCommentAutoGenerated" : true + }, "Carregando sua sessão..." : { "comment" : "A loading message displayed while bootstrapping the user's session.", "isCommentAutoGenerated" : true @@ -264,6 +272,10 @@ "comment" : "A welcome message displayed on the login screen.", "isCommentAutoGenerated" : true }, + "Detalhes" : { + "comment" : "The title of the navigation bar at the top of the product detail sheet.", + "isCommentAutoGenerated" : true + }, "DL" : { "comment" : "An abbreviation for \"Delivery Lady\" used in the user's profile picture circle.", "isCommentAutoGenerated" : true @@ -310,6 +322,10 @@ } } }, + "Fechar" : { + "comment" : "A button to close the current view.", + "isCommentAutoGenerated" : true + }, "Filtros" : { "comment" : "A label for the filter options in the modal.", "isCommentAutoGenerated" : true @@ -381,6 +397,14 @@ } } }, + "inset %@" : { + "comment" : "A label displaying the amount by which the category tabs are inset when they are pinned to the top of the view. The argument is the string “inf”, the string “max+” or the string “min-”.", + "isCommentAutoGenerated" : true + }, + "inset %lld" : { + "comment" : "A label showing the amount of space between the bottom of the category tabs and the top of the viewport.", + "isCommentAutoGenerated" : true + }, "Insira o código de 8 dígitos enviado" : { "comment" : "A description below the text field where the user inputs their OTP code.", "isCommentAutoGenerated" : true @@ -512,6 +536,10 @@ }, "O que vai querer pedir hoje?\n " : { + }, + "offset %@" : { + "comment" : "A label displaying the current scroll offset, useful for debugging The argument is the string “inf”, the string “max+” or the string “min-”.", + "isCommentAutoGenerated" : true }, "para %@" : { "comment" : "A text label displaying the email address to which the one-time password (OTP) was sent. The text is truncated to fit within one line.", @@ -529,6 +557,10 @@ "comment" : "A label for the \"Profile\" tab in the main tab view.", "isCommentAutoGenerated" : true }, + "pin %lld" : { + "comment" : "A label showing whether the category tabs are pinned or not. The argument is a boolean value (`true` if pinned, `false` otherwise)", + "isCommentAutoGenerated" : true + }, "Política de Privacidade" : { "comment" : "The title of the privacy policy section.", "isCommentAutoGenerated" : true @@ -587,6 +619,17 @@ "comment" : "A button that allows a user to request a new OTP code.", "isCommentAutoGenerated" : true }, + "RESTAURANT" : { + + }, + "safe %@" : { + "comment" : "A debug label showing the safe area inset value. The argument is the string “inf”, the string “max+” or the string “min-”.", + "isCommentAutoGenerated" : true + }, + "safe %lld" : { + "comment" : "A text element displaying the safe area inset at the top of the view", + "isCommentAutoGenerated" : true + }, "Save" : { "comment" : "Button title indicating that the current contents should be saved", "extractionState" : "stale", @@ -689,6 +732,10 @@ "comment" : "A link to the app's \"Terms of Use\".", "isCommentAutoGenerated" : true }, + "th %@" : { + "comment" : "A label displaying the threshold at which the category tabs should become pinned. The argument is the string “inf”, the string “max+” or the string “min-”.", + "isCommentAutoGenerated" : true + }, "Title" : { "comment" : "Label for the item editor form indicating the title of the item", "extractionState" : "stale", diff --git a/pedi-foods/Sources/PediFoods/Services/ApiService.swift b/pedi-foods/Sources/PediFoods/Services/ApiService.swift index c5482f9..1da5798 100644 --- a/pedi-foods/Sources/PediFoods/Services/ApiService.swift +++ b/pedi-foods/Sources/PediFoods/Services/ApiService.swift @@ -179,6 +179,16 @@ final class ApiService { let req = ApiRequest(path: "/api/app/stores", method: "GET", module: .app, requiresAuth: true, queryItems: items) return try await sendEnvelope(req) } + + func storeInfo(storeId: String) async throws -> ApiEnvelope { + let req = ApiRequest(path: "/api/store/\(storeId)/info", method: "GET", module: .store, requiresAuth: true) + return try await sendEnvelope(req) + } + + func storeCatalog(storeId: String) async throws -> ApiEnvelope<[StoreCatalogCategory]> { + let req = ApiRequest(path: "/api/store/\(storeId)/catalog", method: "GET", module: .store, requiresAuth: true) + return try await sendEnvelope(req) + } } // MARK: - DTOs @@ -254,6 +264,175 @@ struct StoreSummary: Decodable { let statusLabel: String? } +struct StoreInfoResult: Decodable { + let isOpen: Bool? + let statusLabel: String? + let deliveryTime: String? + let minOrder: Double? + let address: StoreAddressInfo? + let paymentMethods: StorePaymentMethodsInfo? + + enum CodingKeys: String, CodingKey { + case isOpen + case statusLabel + case deliveryTime + case minOrder + case address + case paymentMethods + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + isOpen = try? container.decode(Bool.self, forKey: .isOpen) + statusLabel = try? container.decode(String.self, forKey: .statusLabel) + deliveryTime = try? container.decode(String.self, forKey: .deliveryTime) + minOrder = ApiService.decodeFlexibleDouble(from: container, keys: [.minOrder]) + address = try? container.decode(StoreAddressInfo.self, forKey: .address) + paymentMethods = try? container.decode(StorePaymentMethodsInfo.self, forKey: .paymentMethods) + } +} + +struct StoreAddressInfo: Decodable { + let street: String? + let number: String? + let neighborhood: String? + let city: String? + let state: String? + let latitude: Double? + let longitude: Double? + + enum CodingKeys: String, CodingKey { + case street + case number + case neighborhood + case city + case state + case latitude + case longitude + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + street = try? container.decode(String.self, forKey: .street) + number = try? container.decode(String.self, forKey: .number) + neighborhood = try? container.decode(String.self, forKey: .neighborhood) + city = try? container.decode(String.self, forKey: .city) + state = try? container.decode(String.self, forKey: .state) + latitude = ApiService.decodeFlexibleDouble(from: container, keys: [.latitude]) + longitude = ApiService.decodeFlexibleDouble(from: container, keys: [.longitude]) + } +} + +struct StorePaymentMethodsInfo: Decodable { + let acceptPix: Bool? + let acceptCash: Bool? + let acceptCreditCard: Bool? + let acceptDebitCard: Bool? +} + +struct StoreCatalogCategory: Decodable { + let id: String + let name: String + let products: [StoreCatalogProduct] + + enum CodingKeys: String, CodingKey { + case id + case name + case products + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = (try? container.decode(String.self, forKey: .id)) ?? UUID().uuidString + name = (try? container.decode(String.self, forKey: .name)) ?? "Categoria" + products = (try? container.decode([StoreCatalogProduct].self, forKey: .products)) ?? [] + } +} + +struct StoreCatalogProduct: Decodable, Identifiable { + let id: String + let name: String + let description: String? + let image: String? + let price: Double? + let originalPrice: Double? + let addonGroups: [StoreAddonGroup] + + enum CodingKeys: String, CodingKey { + case id + case name + case description + case desc + case image + case cover + case photo + case price + case originalPrice + case oldPrice + case addonGroups + case addons + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = (try? container.decode(String.self, forKey: .id)) ?? UUID().uuidString + name = (try? container.decode(String.self, forKey: .name)) ?? "Produto" + description = (try? container.decode(String.self, forKey: .description)) ?? (try? container.decode(String.self, forKey: .desc)) + image = (try? container.decode(String.self, forKey: .image)) + ?? (try? container.decode(String.self, forKey: .cover)) + ?? (try? container.decode(String.self, forKey: .photo)) + price = ApiService.decodeFlexibleDouble(from: container, keys: [.price]) + originalPrice = ApiService.decodeFlexibleDouble(from: container, keys: [.originalPrice, .oldPrice]) + addonGroups = (try? container.decode([StoreAddonGroup].self, forKey: .addonGroups)) + ?? (try? container.decode([StoreAddonGroup].self, forKey: .addons)) + ?? [] + } +} + +struct StoreAddonGroup: Decodable, Identifiable { + let id: String + let name: String + let minSelectors: Int? + let maxSelectors: Int? + let items: [StoreAddonItem] + + enum CodingKeys: String, CodingKey { + case id + case name + case minSelectors + case maxSelectors + case items + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = (try? container.decode(String.self, forKey: .id)) ?? UUID().uuidString + name = (try? container.decode(String.self, forKey: .name)) ?? "Adicionais" + minSelectors = try? container.decode(Int.self, forKey: .minSelectors) + maxSelectors = try? container.decode(Int.self, forKey: .maxSelectors) + items = (try? container.decode([StoreAddonItem].self, forKey: .items)) ?? [] + } +} + +struct StoreAddonItem: Decodable, Identifiable { + let id: String + let name: String + let price: Double? + + enum CodingKeys: String, CodingKey { + case id + case name + case price + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = (try? container.decode(String.self, forKey: .id)) ?? UUID().uuidString + name = (try? container.decode(String.self, forKey: .name)) ?? "Item" + price = ApiService.decodeFlexibleDouble(from: container, keys: [.price]) + } +} + struct PublicCategory: Decodable { let id: String let name: String @@ -422,3 +601,25 @@ struct CepLookupResult: Decodable { return nil } } + +private extension ApiService { + static func decodeFlexibleDouble(from container: KeyedDecodingContainer, keys: [K]) -> Double? { + for key in keys { + if let value = try? container.decode(Double.self, forKey: key) { + return value + } + if let asInt = try? container.decode(Int.self, forKey: key) { + return Double(asInt) + } + if let asString = try? container.decode(String.self, forKey: key) { + let normalized = asString + .trimmingCharacters(in: .whitespacesAndNewlines) + .replacingOccurrences(of: ",", with: ".") + if let parsed = Double(normalized) { + return parsed + } + } + } + return nil + } +} diff --git a/pedi-foods/Sources/PediFoods/Views/Main/HomeView.swift b/pedi-foods/Sources/PediFoods/Views/Main/HomeView.swift index 481dc0a..df12551 100644 --- a/pedi-foods/Sources/PediFoods/Views/Main/HomeView.swift +++ b/pedi-foods/Sources/PediFoods/Views/Main/HomeView.swift @@ -83,8 +83,21 @@ struct HomeView: View { ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: 16) { ForEach(featuredStoresCards) { store in - FeaturedStoreCard(store: store) - .frame(width: 190) + NavigationLink { + StoreDetailView( + storeId: store.id, + storeName: store.name, + storeCoverURL: store.coverURL, + storeLogoURL: store.logoURL, + storeCategory: store.category, + storeRating: store.rating, + storeDistance: store.distance + ) + } label: { + FeaturedStoreCard(store: store) + .frame(width: 190) + } + .buttonStyle(.plain) } } .padding(.horizontal, 20) @@ -138,7 +151,20 @@ struct HomeView: View { } else { VStack(spacing: 16) { ForEach(nearbyStoreCards) { store in - FeaturedStoreCard(store: store) + NavigationLink { + StoreDetailView( + storeId: store.id, + storeName: store.name, + storeCoverURL: store.coverURL, + storeLogoURL: store.logoURL, + storeCategory: store.category, + storeRating: store.rating, + storeDistance: store.distance + ) + } label: { + FeaturedStoreCard(store: store) + } + .buttonStyle(.plain) } } .padding(.horizontal, 20) @@ -296,7 +322,9 @@ struct HomeView: View { } private func mapStoreToCard(_ store: StoreSummary) -> FeaturedStoreCardModel { - FeaturedStoreCardModel( + let coverURL = resolveStoreMediaURL(store.cover) + let logoURL = resolveStoreMediaURL(store.logo) + return FeaturedStoreCardModel( id: store.id, name: store.name, rating: store.rating ?? 0, @@ -306,16 +334,15 @@ struct HomeView: View { promoText: nil, isFavorite: appState.favorites.storeIds.contains(store.id), iconName: "storefront", - imageURL: resolveStoreImageURL(logo: store.logo, cover: store.cover) + imageURL: coverURL ?? logoURL, + logoURL: logoURL, + coverURL: coverURL ) } - private func resolveStoreImageURL(logo: String?, cover: String?) -> String? { - let preferred = [logo, cover] - .compactMap { $0?.trimmingCharacters(in: .whitespacesAndNewlines) } - .first { $0.isEmpty == false } - - guard let raw = preferred else { return nil } + private func resolveStoreMediaURL(_ raw: String?) -> String? { + guard let raw = raw?.trimmingCharacters(in: .whitespacesAndNewlines), + raw.isEmpty == false else { return nil } if raw.lowercased().hasPrefix("http://") || raw.lowercased().hasPrefix("https://") { return raw diff --git a/pedi-foods/Sources/PediFoods/Views/Main/StoreDetailView.swift b/pedi-foods/Sources/PediFoods/Views/Main/StoreDetailView.swift new file mode 100644 index 0000000..536ebdb --- /dev/null +++ b/pedi-foods/Sources/PediFoods/Views/Main/StoreDetailView.swift @@ -0,0 +1,755 @@ +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) + } + } + } +}