migration

This commit is contained in:
Daniel Arantes Loverde
2026-08-11 13:22:02 -03:00
parent c4d6998595
commit 7702836fe7
231 changed files with 4329 additions and 1958 deletions

View 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()
}
}
}
}