migration
This commit is contained in:
364
PediFoods/Views/Main/PizzaProductDetailSheet.swift
Normal file
364
PediFoods/Views/Main/PizzaProductDetailSheet.swift
Normal file
@@ -0,0 +1,364 @@
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
struct PizzaProductDetailSheet: View {
|
||||
let category: StoreCatalogCategory
|
||||
let storeId: String
|
||||
let resolveImageURL: (String?) -> String?
|
||||
let currentQuantityForItemId: (String) -> Int
|
||||
let onAdd: (CartItemState) -> Void
|
||||
|
||||
@Environment(\.dismiss) var dismiss
|
||||
|
||||
@State var selectedSizeId: String? = nil
|
||||
@State var selectedDoughId: String? = nil
|
||||
@State var selectedCrustId: String? = nil
|
||||
@State var selectedFlavorIds: Set<String> = []
|
||||
@State var flavorAddonQuantities: [String: [String: Int]] = [:]
|
||||
@State var selectedFlavorForAddons: StoreCatalogProduct? = nil
|
||||
@State var quantity: Int = 1
|
||||
@State var expandedStep: Int = 0
|
||||
|
||||
var flavors: [StoreCatalogProduct] {
|
||||
category.products
|
||||
}
|
||||
|
||||
var pizzaConfig: StorePizzaConfig? {
|
||||
category.pizzaConfig
|
||||
}
|
||||
|
||||
var sizes: [StorePizzaSize] {
|
||||
pizzaConfig?.sizes ?? []
|
||||
}
|
||||
|
||||
var doughs: [StorePizzaDough] {
|
||||
(pizzaConfig?.doughs ?? []).filter { $0.active ?? true }
|
||||
}
|
||||
|
||||
var crusts: [StorePizzaCrust] {
|
||||
(pizzaConfig?.crusts ?? []).filter { $0.active ?? true }
|
||||
}
|
||||
|
||||
private var representativeImage: String? {
|
||||
let firstImage = flavors
|
||||
.compactMap(\.image)
|
||||
.first { $0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false }
|
||||
return resolveImageURL(firstImage)
|
||||
}
|
||||
|
||||
var selectedSize: StorePizzaSize? {
|
||||
guard let selectedSizeId else { return nil }
|
||||
return sizes.first(where: { $0.id == selectedSizeId })
|
||||
}
|
||||
|
||||
private var selectedDoughName: String? {
|
||||
guard let selectedDoughId else { return nil }
|
||||
return doughs.first(where: { $0.id == selectedDoughId })?.name
|
||||
}
|
||||
|
||||
private var selectedCrust: StorePizzaCrust? {
|
||||
guard let selectedCrustId else { return nil }
|
||||
return crusts.first(where: { $0.id == selectedCrustId })
|
||||
}
|
||||
|
||||
var maxFlavorsAllowed: Int {
|
||||
max(1, selectedSize?.maxFlavors ?? 1)
|
||||
}
|
||||
|
||||
var isDoughReady: Bool {
|
||||
selectedSizeId != nil && (doughs.isEmpty || selectedDoughId != nil)
|
||||
}
|
||||
|
||||
var isCrustReady: Bool {
|
||||
isDoughReady && (crusts.isEmpty || selectedCrustId != nil)
|
||||
}
|
||||
|
||||
var canShowFlavors: Bool {
|
||||
isCrustReady
|
||||
}
|
||||
|
||||
var selectedFlavorProducts: [StoreCatalogProduct] {
|
||||
flavors
|
||||
.filter { selectedFlavorIds.contains($0.id) }
|
||||
.sorted { $0.name < $1.name }
|
||||
}
|
||||
|
||||
private var canConfirm: Bool {
|
||||
selectedSizeId != nil && isDoughReady && isCrustReady && selectedFlavorIds.isEmpty == false && quantity > 0
|
||||
}
|
||||
|
||||
private var crustPriceModifier: Double {
|
||||
selectedCrust?.priceModifier ?? 0
|
||||
}
|
||||
|
||||
private var addonsTotal: Double {
|
||||
selectedFlavorProducts.reduce(0) { partial, flavor in
|
||||
let byAddon = flavorAddonQuantities[flavor.id] ?? [:]
|
||||
let pricesByAddon = Dictionary(uniqueKeysWithValues: flavor.addonGroups.flatMap(\.items).map { ($0.id, $0.price ?? 0) })
|
||||
let subtotal = byAddon.reduce(0.0) { line, pair in
|
||||
line + (Double(max(0, pair.value)) * (pricesByAddon[pair.key] ?? 0))
|
||||
}
|
||||
return partial + subtotal
|
||||
}
|
||||
}
|
||||
|
||||
private var basePizzaPrice: Double {
|
||||
let prices = selectedFlavorProducts.map { flavor in
|
||||
guard let selectedSizeId else { return flavor.price ?? 0 }
|
||||
return flavor.pizzaPrices[selectedSizeId] ?? flavor.price ?? 0
|
||||
}
|
||||
guard prices.isEmpty == false else { return 0 }
|
||||
return prices.reduce(0, +) / Double(prices.count)
|
||||
}
|
||||
|
||||
private var unitPrice: Double {
|
||||
basePizzaPrice + crustPriceModifier + addonsTotal
|
||||
}
|
||||
|
||||
private var totalPrice: Double {
|
||||
unitPrice * Double(quantity)
|
||||
}
|
||||
|
||||
private var cartItemId: String {
|
||||
var tokens: [String] = []
|
||||
if let selectedSizeId { tokens.append("size:\(selectedSizeId)") }
|
||||
if let selectedDoughId { tokens.append("dough:\(selectedDoughId)") }
|
||||
if let selectedCrustId { tokens.append("crust:\(selectedCrustId)") }
|
||||
|
||||
let flavorsToken = selectedFlavorIds.sorted().joined(separator: ",")
|
||||
tokens.append("flavors:\(flavorsToken)")
|
||||
|
||||
let addonsToken = flavorAddonQuantities
|
||||
.flatMap { flavorId, addons in
|
||||
addons
|
||||
.filter { $0.value > 0 }
|
||||
.map { "\(flavorId):\($0.key):\($0.value)" }
|
||||
}
|
||||
.sorted()
|
||||
.joined(separator: ",")
|
||||
if addonsToken.isEmpty == false {
|
||||
tokens.append("addons:\(addonsToken)")
|
||||
}
|
||||
|
||||
return "\(storeId)::pizza::\(category.id)::" + tokens.joined(separator: "|")
|
||||
}
|
||||
|
||||
private var selectedAddonsPayload: [CartItemAddonState] {
|
||||
var payload: [CartItemAddonState] = []
|
||||
for flavor in selectedFlavorProducts {
|
||||
let byAddon = flavorAddonQuantities[flavor.id] ?? [:]
|
||||
let addonMap = Dictionary(uniqueKeysWithValues: flavor.addonGroups.flatMap(\.items).map { ($0.id, $0) })
|
||||
for (addonId, qty) in byAddon {
|
||||
guard qty > 0, let addon = addonMap[addonId] else { continue }
|
||||
payload.append(
|
||||
CartItemAddonState(
|
||||
id: "\(flavor.id)::\(addon.id)",
|
||||
name: "\(flavor.name) • \(addon.name)",
|
||||
quantity: qty,
|
||||
unitPrice: addon.price ?? 0
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
private var selectedDetailsText: String? {
|
||||
var chunks: [String] = []
|
||||
if let selectedSizeName = selectedSize?.name {
|
||||
chunks.append("Tamanho: \(selectedSizeName)")
|
||||
}
|
||||
if let selectedDoughName, selectedDoughName.isEmpty == false {
|
||||
chunks.append("Massa: \(selectedDoughName)")
|
||||
}
|
||||
if let crustName = selectedCrust?.name, crustName.isEmpty == false {
|
||||
chunks.append("Borda: \(crustName)")
|
||||
}
|
||||
if selectedFlavorProducts.isEmpty == false {
|
||||
chunks.append("Sabores: " + selectedFlavorProducts.map(\.name).joined(separator: ", "))
|
||||
}
|
||||
return chunks.isEmpty ? nil : chunks.joined(separator: " • ")
|
||||
}
|
||||
|
||||
private var pizzaChoices: [String] {
|
||||
var choices: [String] = []
|
||||
if let sizeName = selectedSize?.name {
|
||||
let sizePrice = basePizzaPrice
|
||||
if sizePrice > 0 {
|
||||
choices.append("Tamanho: \(sizeName) (+\(formatCurrency(sizePrice)))")
|
||||
} else {
|
||||
choices.append("Tamanho: \(sizeName)")
|
||||
}
|
||||
}
|
||||
if let crustName = selectedCrust?.name, crustName.isEmpty == false {
|
||||
let mod = crustPriceModifier
|
||||
if mod > 0 {
|
||||
choices.append("Borda: \(crustName) (+\(formatCurrency(mod)))")
|
||||
} else {
|
||||
choices.append("Borda: \(crustName)")
|
||||
}
|
||||
}
|
||||
if let doughName = selectedDoughName, doughName.isEmpty == false {
|
||||
choices.append("Massa: \(doughName)")
|
||||
}
|
||||
let flavorCount = selectedFlavorProducts.count
|
||||
for flavor in selectedFlavorProducts {
|
||||
choices.append(flavorCount > 1 ? "Meio \(flavor.name)" : flavor.name)
|
||||
}
|
||||
return choices
|
||||
}
|
||||
|
||||
private var addButtonTitle: String {
|
||||
if canConfirm == false {
|
||||
return "Selecione as opções"
|
||||
}
|
||||
return "Adicionar • \(formatCurrency(totalPrice))"
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ScrollView(showsIndicators: false) {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
screenHeader
|
||||
Rectangle()
|
||||
.fill(AppColors.brandSoft)
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 220)
|
||||
.overlay(
|
||||
Image("placeholder-pizza")
|
||||
.resizable()
|
||||
.scaledToFill()
|
||||
.clipped()
|
||||
)
|
||||
.clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusXL, style: .continuous))
|
||||
|
||||
Text("Escolha seu sabor")
|
||||
.font(AppTypography.heading2)
|
||||
.foregroundStyle(AppColors.textPrimary)
|
||||
|
||||
Text("Escolha o tamanho da sua fome")
|
||||
.font(AppTypography.body)
|
||||
.foregroundStyle(AppColors.textMuted)
|
||||
|
||||
Text(formatCurrency(unitPrice))
|
||||
.font(AppTypography.heading2)
|
||||
.foregroundStyle(AppColors.primary)
|
||||
|
||||
stepSizes
|
||||
if selectedSizeId != nil { stepDoughs }
|
||||
if isDoughReady { stepCrusts }
|
||||
if canShowFlavors { stepFlavors }
|
||||
}
|
||||
.padding(20)
|
||||
.padding(.bottom, 90)
|
||||
}
|
||||
.appBottomSafeAreaInset {
|
||||
HStack(spacing: 12) {
|
||||
HStack(spacing: 10) {
|
||||
Button(action: { if quantity > 1 { quantity -= 1 } }) {
|
||||
Image(systemName: "minus")
|
||||
.font(.system(size: 14, weight: .bold))
|
||||
.foregroundStyle(AppColors.textPrimary)
|
||||
.frame(width: 32, height: 32)
|
||||
.background(AppColors.surface)
|
||||
.clipShape(Circle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(quantity <= 1)
|
||||
|
||||
Text("\(quantity)")
|
||||
.font(AppTypography.heading3)
|
||||
.foregroundStyle(AppColors.textPrimary)
|
||||
.frame(minWidth: 20)
|
||||
|
||||
Button(action: { quantity += 1 }) {
|
||||
Image(systemName: "plus")
|
||||
.font(.system(size: 14, weight: .bold))
|
||||
.foregroundStyle(AppColors.textPrimary)
|
||||
.frame(width: 32, height: 32)
|
||||
.background(AppColors.surface)
|
||||
.clipShape(Circle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
.padding(.horizontal, 8)
|
||||
.frame(height: 48)
|
||||
.background(AppColors.surface)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous))
|
||||
|
||||
PrimaryButton(title: addButtonTitle) {
|
||||
guard canConfirm else { return }
|
||||
let item = CartItemState(
|
||||
id: cartItemId,
|
||||
productId: selectedFlavorProducts.first?.id ?? category.id,
|
||||
storeId: storeId,
|
||||
name: "Escolha seu sabor",
|
||||
imageURL: representativeImage,
|
||||
details: selectedDetailsText,
|
||||
choices: pizzaChoices.isEmpty ? nil : pizzaChoices,
|
||||
addons: selectedAddonsPayload,
|
||||
quantity: quantity,
|
||||
unitPrice: unitPrice
|
||||
)
|
||||
onAdd(item)
|
||||
dismiss()
|
||||
}
|
||||
.disabled(canConfirm == false)
|
||||
}
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.top, 8)
|
||||
.padding(.bottom, 12)
|
||||
.background(.ultraThinMaterial)
|
||||
}
|
||||
.sheet(item: $selectedFlavorForAddons) { flavor in
|
||||
NavigationStack {
|
||||
PizzaFlavorAddonsSheet(
|
||||
flavor: flavor,
|
||||
quantities: Binding(
|
||||
get: { flavorAddonQuantities[flavor.id] ?? [:] },
|
||||
set: { flavorAddonQuantities[flavor.id] = $0 }
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
.background(AppColors.backgroundLight.ignoresSafeArea())
|
||||
.appHiddenNavigationBar()
|
||||
.navigationBarBackButtonHidden(true)
|
||||
.onAppear {
|
||||
applyAutoSelections()
|
||||
let existing = currentQuantityForItemId(cartItemId)
|
||||
if existing > 0 {
|
||||
quantity = existing
|
||||
}
|
||||
}
|
||||
.onChange(of: selectedSizeId) { _, _ in
|
||||
trimFlavorSelectionByLimit()
|
||||
applyAutoSelections()
|
||||
}
|
||||
.onChange(of: selectedFlavorIds) { _, newValue in
|
||||
let selected = newValue
|
||||
flavorAddonQuantities = flavorAddonQuantities.filter { selected.contains($0.key) }
|
||||
}
|
||||
}
|
||||
|
||||
private var screenHeader: some View {
|
||||
ZStack {
|
||||
Text("Monte sua pizza")
|
||||
.font(AppTypography.heading2)
|
||||
.foregroundStyle(AppColors.textPrimary)
|
||||
HStack {
|
||||
Button(action: { dismiss() }) {
|
||||
Image(systemName: "chevron.left")
|
||||
.font(.system(size: 24, weight: .semibold))
|
||||
.foregroundStyle(AppColors.textPrimary)
|
||||
.frame(width: 52, height: 52)
|
||||
.background(AppColors.surface)
|
||||
.clipShape(Circle())
|
||||
.shadow(color: .black.opacity(0.06), radius: 8, y: 2)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user