fix(store): position categories view below dynamic island using reactive safe area top reader & refactor layout components

This commit is contained in:
Daniel Arantes Loverde
2026-06-03 11:29:27 -03:00
parent 9fb8dae45a
commit 235b6d44cc
43 changed files with 435 additions and 413 deletions

View File

@@ -0,0 +1,348 @@
import SwiftUI
extension StoreDetailView {
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)
}
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
)
)
.ignoresSafeArea(.container, edges: .top)
VStack(spacing: 0) {
HStack {
heroIconButton(icon: "chevron.left") {
dismiss()
}
Spacer()
heroIconButton(icon: "magnifyingglass") {}
heroIconButton(
icon: isFavoriteStore ? "heart.fill" : "heart",
foregroundStyle: isFavoriteStore ? Color.red : Color.white
) {
Task {
await toggleFavoriteStore()
}
}
}
.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)
}
}
}
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)
}
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)
if let deliveryTime = info?.deliveryTime {
statItem(title: "TEMPO MIN.", value: deliveryTime+" min.")
} else {
statItem(title: "TEMPO", value: "--")
}
Divider().frame(height: 34)
statItem(title: "PED. MIN.", 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
var sectionedProducts: some View {
if isLoading {
VStack(spacing: 10) {
ProgressView()
Text("Carregando cardápio...")
.font(AppTypography.body)
.foregroundStyle(AppColors.textMuted)
}
.frame(maxWidth: .infinity, minHeight: 280, alignment: .center)
} 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
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]
)
}
)
VStack(spacing: 12) {
ForEach(listItems(for: category)) { item in
productCard(item, in: category)
}
}
.padding(.horizontal, 16)
.padding(.bottom, 8)
}
Color.clear.frame(height: 140)
}
}
func categoryTabs(proxy: ScrollViewProxy, topExtension: CGFloat = 0, stickyProgress: CGFloat = 0) -> some View {
let chipPaddingH: CGFloat = 10 + 6 * stickyProgress
let chipPaddingV: CGFloat = 4 + 5 * stickyProgress
let containerPaddingV: CGFloat = 2 + 8 * stickyProgress
return 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, chipPaddingH)
.padding(.vertical, chipPaddingV)
.background(active ? AppColors.primary : AppColors.surface)
.clipShape(Capsule())
}
.buttonStyle(.plain)
}
}
.padding(.horizontal, 16)
.padding(.vertical, containerPaddingV)
}
.background(AppColors.backgroundLight)
.overlay(alignment: .top) {
AppColors.backgroundLight
.frame(height: topExtension)
.frame(maxWidth: .infinity)
.offset(y: -topExtension)
.allowsHitTesting(false)
}
}
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)
}
}
}