fix(store): position categories view below dynamic island using reactive safe area top reader & refactor layout components
@@ -1,41 +1,35 @@
|
|||||||
{
|
{
|
||||||
"images" : [
|
"images" : [
|
||||||
{ "filename" : "icon_iphone_20pt@2x.png", "idiom" : "iphone", "scale" : "2x", "size" : "20x20" },
|
{
|
||||||
{ "appearances" : [ { "appearance" : "luminosity", "value" : "dark" } ], "filename" : "icon_iphone_20pt@2x_dark.png", "idiom" : "iphone", "scale" : "2x", "size" : "20x20" },
|
"filename" : "icon_pattern_1024x1024.png",
|
||||||
{ "filename" : "icon_iphone_20pt@3x.png", "idiom" : "iphone", "scale" : "3x", "size" : "20x20" },
|
"idiom" : "universal",
|
||||||
{ "appearances" : [ { "appearance" : "luminosity", "value" : "dark" } ], "filename" : "icon_iphone_20pt@3x_dark.png", "idiom" : "iphone", "scale" : "3x", "size" : "20x20" },
|
"platform" : "ios",
|
||||||
{ "filename" : "icon_iphone_29pt@2x.png", "idiom" : "iphone", "scale" : "2x", "size" : "29x29" },
|
"size" : "1024x1024"
|
||||||
{ "appearances" : [ { "appearance" : "luminosity", "value" : "dark" } ], "filename" : "icon_iphone_29pt@2x_dark.png", "idiom" : "iphone", "scale" : "2x", "size" : "29x29" },
|
},
|
||||||
{ "filename" : "icon_iphone_29pt@3x.png", "idiom" : "iphone", "scale" : "3x", "size" : "29x29" },
|
{
|
||||||
{ "appearances" : [ { "appearance" : "luminosity", "value" : "dark" } ], "filename" : "icon_iphone_29pt@3x_dark.png", "idiom" : "iphone", "scale" : "3x", "size" : "29x29" },
|
"appearances" : [
|
||||||
{ "filename" : "icon_iphone_40pt@2x.png", "idiom" : "iphone", "scale" : "2x", "size" : "40x40" },
|
{
|
||||||
{ "appearances" : [ { "appearance" : "luminosity", "value" : "dark" } ], "filename" : "icon_iphone_40pt@2x_dark.png", "idiom" : "iphone", "scale" : "2x", "size" : "40x40" },
|
"appearance" : "luminosity",
|
||||||
{ "filename" : "icon_iphone_40pt@3x.png", "idiom" : "iphone", "scale" : "3x", "size" : "40x40" },
|
"value" : "dark"
|
||||||
{ "appearances" : [ { "appearance" : "luminosity", "value" : "dark" } ], "filename" : "icon_iphone_40pt@3x_dark.png", "idiom" : "iphone", "scale" : "3x", "size" : "40x40" },
|
}
|
||||||
{ "filename" : "icon_iphone_60pt@2x.png", "idiom" : "iphone", "scale" : "2x", "size" : "60x60" },
|
],
|
||||||
{ "appearances" : [ { "appearance" : "luminosity", "value" : "dark" } ], "filename" : "icon_iphone_60pt@2x_dark.png", "idiom" : "iphone", "scale" : "2x", "size" : "60x60" },
|
"filename" : "icon_pattern_dark_1024x1024.png",
|
||||||
{ "filename" : "icon_iphone_60pt@3x.png", "idiom" : "iphone", "scale" : "3x", "size" : "60x60" },
|
"idiom" : "universal",
|
||||||
{ "appearances" : [ { "appearance" : "luminosity", "value" : "dark" } ], "filename" : "icon_iphone_60pt@3x_dark.png", "idiom" : "iphone", "scale" : "3x", "size" : "60x60" },
|
"platform" : "ios",
|
||||||
{ "filename" : "icon_ipad_20pt@1x.png", "idiom" : "ipad", "scale" : "1x", "size" : "20x20" },
|
"size" : "1024x1024"
|
||||||
{ "appearances" : [ { "appearance" : "luminosity", "value" : "dark" } ], "filename" : "icon_ipad_20pt@1x_dark.png", "idiom" : "ipad", "scale" : "1x", "size" : "20x20" },
|
},
|
||||||
{ "filename" : "icon_ipad_20pt@2x.png", "idiom" : "ipad", "scale" : "2x", "size" : "20x20" },
|
{
|
||||||
{ "appearances" : [ { "appearance" : "luminosity", "value" : "dark" } ], "filename" : "icon_ipad_20pt@2x_dark.png", "idiom" : "ipad", "scale" : "2x", "size" : "20x20" },
|
"appearances" : [
|
||||||
{ "filename" : "icon_ipad_29pt@1x.png", "idiom" : "ipad", "scale" : "1x", "size" : "29x29" },
|
{
|
||||||
{ "appearances" : [ { "appearance" : "luminosity", "value" : "dark" } ], "filename" : "icon_ipad_29pt@1x_dark.png", "idiom" : "ipad", "scale" : "1x", "size" : "29x29" },
|
"appearance" : "luminosity",
|
||||||
{ "filename" : "icon_ipad_29pt@2x.png", "idiom" : "ipad", "scale" : "2x", "size" : "29x29" },
|
"value" : "tinted"
|
||||||
{ "appearances" : [ { "appearance" : "luminosity", "value" : "dark" } ], "filename" : "icon_ipad_29pt@2x_dark.png", "idiom" : "ipad", "scale" : "2x", "size" : "29x29" },
|
}
|
||||||
{ "filename" : "icon_ipad_40pt@1x.png", "idiom" : "ipad", "scale" : "1x", "size" : "40x40" },
|
],
|
||||||
{ "appearances" : [ { "appearance" : "luminosity", "value" : "dark" } ], "filename" : "icon_ipad_40pt@1x_dark.png", "idiom" : "ipad", "scale" : "1x", "size" : "40x40" },
|
"filename" : "icon_pattern_1024x1024 1.png",
|
||||||
{ "filename" : "icon_ipad_40pt@2x.png", "idiom" : "ipad", "scale" : "2x", "size" : "40x40" },
|
"idiom" : "universal",
|
||||||
{ "appearances" : [ { "appearance" : "luminosity", "value" : "dark" } ], "filename" : "icon_ipad_40pt@2x_dark.png", "idiom" : "ipad", "scale" : "2x", "size" : "40x40" },
|
"platform" : "ios",
|
||||||
{ "filename" : "icon_ipad_76pt@1x.png", "idiom" : "ipad", "scale" : "1x", "size" : "76x76" },
|
"size" : "1024x1024"
|
||||||
{ "appearances" : [ { "appearance" : "luminosity", "value" : "dark" } ], "filename" : "icon_ipad_76pt@1x_dark.png", "idiom" : "ipad", "scale" : "1x", "size" : "76x76" },
|
}
|
||||||
{ "filename" : "icon_ipad_76pt@2x.png", "idiom" : "ipad", "scale" : "2x", "size" : "76x76" },
|
|
||||||
{ "appearances" : [ { "appearance" : "luminosity", "value" : "dark" } ], "filename" : "icon_ipad_76pt@2x_dark.png", "idiom" : "ipad", "scale" : "2x", "size" : "76x76" },
|
|
||||||
{ "filename" : "icon_ipad_83.5pt@2x.png", "idiom" : "ipad", "scale" : "2x", "size" : "83.5x83.5" },
|
|
||||||
{ "appearances" : [ { "appearance" : "luminosity", "value" : "dark" } ], "filename" : "icon_ipad_83.5pt@2x_dark.png", "idiom" : "ipad", "scale" : "2x", "size" : "83.5x83.5" },
|
|
||||||
{ "filename" : "icon_ios-marketing_1024pt@1x.png", "idiom" : "ios-marketing", "scale" : "1x", "size" : "1024x1024" },
|
|
||||||
{ "appearances" : [ { "appearance" : "luminosity", "value" : "dark" } ], "filename" : "icon_ios-marketing_1024pt@1x_dark.png", "idiom" : "ios-marketing", "scale" : "1x", "size" : "1024x1024" }
|
|
||||||
],
|
],
|
||||||
"info" : {
|
"info" : {
|
||||||
"author" : "xcode",
|
"author" : "xcode",
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 64 KiB |
|
Before Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 2.2 KiB |
|
Before Width: | Height: | Size: 2.3 KiB |
|
Before Width: | Height: | Size: 1.8 KiB |
|
Before Width: | Height: | Size: 1.8 KiB |
|
Before Width: | Height: | Size: 2.9 KiB |
|
Before Width: | Height: | Size: 3.0 KiB |
|
Before Width: | Height: | Size: 2.2 KiB |
|
Before Width: | Height: | Size: 2.3 KiB |
|
Before Width: | Height: | Size: 3.8 KiB |
|
Before Width: | Height: | Size: 4.0 KiB |
|
Before Width: | Height: | Size: 3.6 KiB |
|
Before Width: | Height: | Size: 3.8 KiB |
|
Before Width: | Height: | Size: 6.7 KiB |
|
Before Width: | Height: | Size: 7.2 KiB |
|
Before Width: | Height: | Size: 7.3 KiB |
|
Before Width: | Height: | Size: 7.8 KiB |
|
Before Width: | Height: | Size: 2.2 KiB |
|
Before Width: | Height: | Size: 2.3 KiB |
|
Before Width: | Height: | Size: 3.0 KiB |
|
Before Width: | Height: | Size: 3.1 KiB |
|
Before Width: | Height: | Size: 2.9 KiB |
|
Before Width: | Height: | Size: 3.0 KiB |
|
Before Width: | Height: | Size: 4.0 KiB |
|
Before Width: | Height: | Size: 4.3 KiB |
|
Before Width: | Height: | Size: 3.8 KiB |
|
Before Width: | Height: | Size: 4.0 KiB |
|
Before Width: | Height: | Size: 5.3 KiB |
|
Before Width: | Height: | Size: 5.6 KiB |
|
Before Width: | Height: | Size: 5.3 KiB |
|
Before Width: | Height: | Size: 5.6 KiB |
|
Before Width: | Height: | Size: 7.9 KiB |
|
Before Width: | Height: | Size: 8.5 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 35 KiB |
@@ -61,3 +61,31 @@ struct AsyncStoreImage: View {
|
|||||||
.scaledToFill()
|
.scaledToFill()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct SafeAreaTopPreferenceKey: PreferenceKey {
|
||||||
|
static let defaultValue: CGFloat = 0
|
||||||
|
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
|
||||||
|
let next = nextValue()
|
||||||
|
if next > 0 {
|
||||||
|
value = next
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct SafeAreaTopReader: View {
|
||||||
|
@Binding var safeAreaTop: CGFloat
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
GeometryReader { geometry in
|
||||||
|
Color.clear
|
||||||
|
.preference(key: SafeAreaTopPreferenceKey.self, value: geometry.safeAreaInsets.top)
|
||||||
|
}
|
||||||
|
.frame(height: 0)
|
||||||
|
.onPreferenceChange(SafeAreaTopPreferenceKey.self) { value in
|
||||||
|
if value > 0 && safeAreaTop != value {
|
||||||
|
safeAreaTop = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@ struct StoreDetailView: View {
|
|||||||
let storeCategory: String?
|
let storeCategory: String?
|
||||||
let storeRating: Double?
|
let storeRating: Double?
|
||||||
let storeDistance: String?
|
let storeDistance: String?
|
||||||
|
let storeDeliveryFee: Double?
|
||||||
@Binding var appState: AppState
|
@Binding var appState: AppState
|
||||||
|
|
||||||
@Environment(\.dismiss) var dismiss
|
@Environment(\.dismiss) var dismiss
|
||||||
@@ -33,60 +34,50 @@ struct StoreDetailView: View {
|
|||||||
@State var didLoad = false
|
@State var didLoad = false
|
||||||
@State var categoryHeaderOffsets: [String: CGFloat] = [:]
|
@State var categoryHeaderOffsets: [String: CGFloat] = [:]
|
||||||
@State var isProgrammaticCategoryScroll = false
|
@State var isProgrammaticCategoryScroll = false
|
||||||
@State var scrollOffsetY: CGFloat = 0
|
@State var scrollOffset: CGFloat = 0
|
||||||
|
@State var safeAreaTop: CGFloat = 0
|
||||||
@State var isFavoriteRequestInFlight = false
|
@State var isFavoriteRequestInFlight = false
|
||||||
|
|
||||||
let cardTopInset: CGFloat = 168
|
let cardTopInset: CGFloat = 180
|
||||||
let summaryCardBaseHeight: CGFloat = 170
|
let summaryCardBaseHeight: CGFloat = 212
|
||||||
let closedBannerHeight: CGFloat = 44
|
let closedBannerHeight: CGFloat = 44
|
||||||
let coverVisibleUntilY: CGFloat = 253
|
let coverVisibleUntilY: CGFloat = 253
|
||||||
let storeLogoSize: CGFloat = 84
|
let storeLogoSize: CGFloat = 84
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
GeometryReader { geometry in
|
ScrollViewReader { proxy in
|
||||||
let safeTop = geometry.safeAreaInsets.top
|
let stickyProgress = categoryTabsStickyProgress(safeTop: safeAreaTop)
|
||||||
ScrollViewReader { proxy in
|
|
||||||
|
|
||||||
ZStack(alignment: .top) {
|
|
||||||
AppColors.backgroundLight.ignoresSafeArea()
|
|
||||||
|
|
||||||
ScrollView(showsIndicators: false) {
|
ZStack(alignment: .top) {
|
||||||
#if os(Android)
|
SafeAreaTopReader(safeAreaTop: $safeAreaTop)
|
||||||
LazyVStack(spacing: 0) {
|
|
||||||
ScrollOffsetReader(offsetY: $scrollOffsetY)
|
|
||||||
topSection
|
|
||||||
|
|
||||||
categoryTabs(proxy: proxy, safeTop: safeTop, isSticky: false)
|
AppColors.backgroundLight.ignoresSafeArea()
|
||||||
|
|
||||||
|
ScrollView(showsIndicators: false) {
|
||||||
|
LazyVStack(spacing: 0, pinnedViews: [.sectionHeaders]) {
|
||||||
|
ScrollOffsetReader(offsetY: $scrollOffset)
|
||||||
|
topSection
|
||||||
|
Section {
|
||||||
sectionedProducts
|
sectionedProducts
|
||||||
|
} header: {
|
||||||
|
categoryTabs(proxy: proxy, topExtension: 0, stickyProgress: 1)
|
||||||
|
.opacity(Double(1 - stickyProgress))
|
||||||
}
|
}
|
||||||
#else
|
|
||||||
LazyVStack(spacing: 0, pinnedViews: [.sectionHeaders]) {
|
|
||||||
ScrollOffsetReader(offsetY: $scrollOffsetY)
|
|
||||||
topSection
|
|
||||||
|
|
||||||
categoryTabs(proxy: proxy, safeTop: safeTop, isSticky: false)
|
|
||||||
|
|
||||||
sectionedProducts
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
}
|
}
|
||||||
.refreshable {
|
}
|
||||||
await loadStoreData(forceRefresh: true)
|
.coordinateSpace(name: StoreDetailScrollCoordinateSpace.name)
|
||||||
}
|
.refreshable {
|
||||||
.appNamedCoordinateSpace(StoreDetailScrollCoordinateSpace.name)
|
await loadStoreData(forceRefresh: true)
|
||||||
.ignoresSafeArea(edges: .top)
|
|
||||||
}
|
}
|
||||||
.ignoresSafeArea(edges: .top)
|
.ignoresSafeArea(edges: .top)
|
||||||
.overlay(alignment: .top) {
|
|
||||||
if isCategoryTabsPinned(safeTop: safeTop) {
|
categoryTabs(proxy: proxy, topExtension: stickyProgress * safeAreaTop, stickyProgress: 1)
|
||||||
categoryTabs(proxy: proxy, safeTop: safeTop, isSticky: true)
|
.opacity(Double(stickyProgress))
|
||||||
.transition(.opacity)
|
.allowsHitTesting(stickyProgress > 0.1)
|
||||||
.zIndex(20)
|
.offset(y: safeAreaTop)
|
||||||
}
|
|
||||||
}
|
|
||||||
.saturation(isStoreOpen ? 1 : 0)
|
|
||||||
}
|
}
|
||||||
|
.ignoresSafeArea(edges: .top)
|
||||||
|
.saturation(isStoreOpen ? 1 : 0)
|
||||||
}
|
}
|
||||||
.navigationBarBackButtonHidden(true)
|
.navigationBarBackButtonHidden(true)
|
||||||
.appHiddenNavigationBar()
|
.appHiddenNavigationBar()
|
||||||
@@ -180,343 +171,4 @@ struct StoreDetailView: View {
|
|||||||
syncCategoryWithScroll()
|
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: 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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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 ?? "--")
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||