Commit
This commit is contained in:
293
Sources/PediFoods/Views/Main/PizzaProductDetailSheet+Flow.swift
Normal file
293
Sources/PediFoods/Views/Main/PizzaProductDetailSheet+Flow.swift
Normal file
@@ -0,0 +1,293 @@
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
extension PizzaProductDetailSheet {
|
||||
|
||||
// MARK: - Steps
|
||||
|
||||
var stepSizes: some View {
|
||||
accordionSection(
|
||||
step: 0,
|
||||
label: "Tamanho",
|
||||
summary: selectedSize.map { "\($0.name ?? "") • Até \(max(1, $0.maxFlavors ?? 1)) sabor(es)" }
|
||||
) {
|
||||
ForEach(sizes) { size in
|
||||
radioRow(
|
||||
title: size.name ?? "Tamanho",
|
||||
subtitle: "Até \(max(1, size.maxFlavors ?? 1)) sabor(es)",
|
||||
isSelected: selectedSizeId == size.id
|
||||
) {
|
||||
selectedSizeId = size.id
|
||||
applyAutoSelections()
|
||||
withAnimation(.easeInOut(duration: 0.2)) {
|
||||
expandedStep = nextStep(after: 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var stepDoughs: some View {
|
||||
accordionSection(
|
||||
step: 1,
|
||||
label: "Massa",
|
||||
summary: doughs.count <= 1
|
||||
? (doughs.first?.name ?? "Tradicional")
|
||||
: doughs.first(where: { $0.id == selectedDoughId })?.name
|
||||
) {
|
||||
if doughs.count <= 1 {
|
||||
Text(doughs.first?.name ?? "Massa tradicional")
|
||||
.font(AppTypography.body)
|
||||
.foregroundStyle(AppColors.textPrimary)
|
||||
} else {
|
||||
ForEach(doughs) { dough in
|
||||
radioRow(
|
||||
title: dough.name ?? "Massa",
|
||||
subtitle: nil,
|
||||
isSelected: selectedDoughId == dough.id
|
||||
) {
|
||||
selectedDoughId = dough.id
|
||||
applyAutoSelections()
|
||||
withAnimation(.easeInOut(duration: 0.2)) {
|
||||
expandedStep = nextStep(after: 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var stepCrusts: some View {
|
||||
accordionSection(
|
||||
step: 2,
|
||||
label: "Borda",
|
||||
summary: crusts.count <= 1
|
||||
? crustDescription(crusts.first)
|
||||
: crusts.first(where: { $0.id == selectedCrustId }).map { crustDescription($0) }
|
||||
) {
|
||||
if crusts.count <= 1 {
|
||||
Text(crustDescription(crusts.first))
|
||||
.font(AppTypography.body)
|
||||
.foregroundStyle(AppColors.textPrimary)
|
||||
} else {
|
||||
ForEach(crusts) { crust in
|
||||
radioRow(
|
||||
title: crust.name ?? "Borda",
|
||||
subtitle: (crust.priceModifier ?? 0) > 0 ? "+ \(formatCurrency(crust.priceModifier ?? 0))" : nil,
|
||||
isSelected: selectedCrustId == crust.id
|
||||
) {
|
||||
selectedCrustId = crust.id
|
||||
withAnimation(.easeInOut(duration: 0.2)) {
|
||||
expandedStep = 3
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var stepFlavors: some View {
|
||||
accordionSection(
|
||||
step: 3,
|
||||
label: "Sabores (\(selectedFlavorIds.count)/\(maxFlavorsAllowed))",
|
||||
summary: selectedFlavorIds.isEmpty ? nil
|
||||
: selectedFlavorProducts.map(\.name).joined(separator: ", ")
|
||||
) {
|
||||
Text("Toque no sabor para escolher adicionais.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(AppColors.textMuted)
|
||||
|
||||
ForEach(flavors) { flavor in
|
||||
let isSelected = selectedFlavorIds.contains(flavor.id)
|
||||
let price = selectedSizeId.flatMap { flavor.pizzaPrices[$0] } ?? flavor.price
|
||||
let maxReached = selectedFlavorIds.count >= maxFlavorsAllowed
|
||||
|
||||
HStack(spacing: 10) {
|
||||
AsyncStoreImage(imageURL: resolveImageURL(flavor.image))
|
||||
.frame(width: 52, height: 52)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous))
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(flavor.name)
|
||||
.font(AppTypography.body)
|
||||
.foregroundStyle(AppColors.textPrimary)
|
||||
if let price {
|
||||
Text(formatCurrency(price))
|
||||
.font(.caption)
|
||||
.foregroundStyle(AppColors.textMuted)
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
Toggle("", isOn: Binding(
|
||||
get: { isSelected },
|
||||
set: { value in
|
||||
if value { addFlavor(flavor.id) } else { removeFlavor(flavor.id) }
|
||||
}
|
||||
))
|
||||
.labelsHidden()
|
||||
.disabled(!isSelected && maxReached)
|
||||
}
|
||||
.padding(.vertical, 2)
|
||||
.appContentShape(Rectangle())
|
||||
.onTapGesture {
|
||||
guard isSelected else { return }
|
||||
guard flavor.addonGroups.contains(where: { $0.items.isEmpty == false }) else { return }
|
||||
selectedFlavorForAddons = flavor
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Accordion container
|
||||
|
||||
func accordionSection(
|
||||
step: Int,
|
||||
label: String,
|
||||
summary: String?,
|
||||
@ViewBuilder content: () -> some View
|
||||
) -> some View {
|
||||
let isExpanded = expandedStep == step
|
||||
let isDone = summary != nil
|
||||
|
||||
return VStack(spacing: 0) {
|
||||
Button {
|
||||
withAnimation(.easeInOut(duration: 0.2)) {
|
||||
expandedStep = isExpanded ? -1 : step
|
||||
}
|
||||
} label: {
|
||||
HStack(spacing: 10) {
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(isDone || isExpanded ? AppColors.primary : AppColors.textMuted.opacity(0.25))
|
||||
.frame(width: 26, height: 26)
|
||||
if isDone && !isExpanded {
|
||||
Image(systemName: "checkmark")
|
||||
.font(.system(size: 11, weight: .bold))
|
||||
.foregroundStyle(.white)
|
||||
} else {
|
||||
Text("\(step + 1)")
|
||||
.font(.system(size: 12, weight: .bold))
|
||||
.foregroundStyle(.white)
|
||||
}
|
||||
}
|
||||
|
||||
Text(label)
|
||||
.font(AppTypography.heading3)
|
||||
.foregroundStyle(AppColors.textPrimary)
|
||||
|
||||
Spacer()
|
||||
|
||||
if let summary, !isExpanded {
|
||||
Text(summary)
|
||||
.font(AppTypography.caption)
|
||||
.foregroundStyle(AppColors.textMuted)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
.frame(maxWidth: 140, alignment: .trailing)
|
||||
}
|
||||
|
||||
Image(systemName: isExpanded ? "chevron.up" : "chevron.down")
|
||||
.font(.system(size: 12, weight: .semibold))
|
||||
.foregroundStyle(AppColors.textMuted)
|
||||
}
|
||||
.padding(14)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
if isExpanded {
|
||||
Divider().padding(.horizontal, 14)
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
content()
|
||||
}
|
||||
.padding(14)
|
||||
.transition(.opacity.combined(with: .move(edge: .top)))
|
||||
}
|
||||
}
|
||||
.background(AppColors.surface)
|
||||
.clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous))
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
func nextStep(after step: Int) -> Int {
|
||||
if step == 0 {
|
||||
if doughs.count > 1 { return 1 }
|
||||
if crusts.count > 1 { return 2 }
|
||||
return 3
|
||||
}
|
||||
if step == 1 {
|
||||
if crusts.count > 1 { return 2 }
|
||||
return 3
|
||||
}
|
||||
return 3
|
||||
}
|
||||
|
||||
func radioRow(title: String, subtitle: String?, isSelected: Bool, action: @escaping () -> Void) -> some View {
|
||||
Button(action: action) {
|
||||
HStack(spacing: 10) {
|
||||
ZStack {
|
||||
Circle()
|
||||
.stroke(isSelected ? AppColors.primary : AppColors.textMuted.opacity(0.4), lineWidth: 2)
|
||||
.frame(width: 20, height: 20)
|
||||
if isSelected {
|
||||
Circle()
|
||||
.fill(AppColors.primary)
|
||||
.frame(width: 10, height: 10)
|
||||
}
|
||||
}
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(title)
|
||||
.font(AppTypography.body)
|
||||
.foregroundStyle(AppColors.textPrimary)
|
||||
if let subtitle, subtitle.isEmpty == false {
|
||||
Text(subtitle)
|
||||
.font(.caption)
|
||||
.foregroundStyle(AppColors.textMuted)
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
.appContentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
func crustDescription(_ crust: StorePizzaCrust?) -> String {
|
||||
guard let crust else { return "Sem borda especial" }
|
||||
let name = crust.name ?? "Borda"
|
||||
let modifier = crust.priceModifier ?? 0
|
||||
return modifier > 0 ? "\(name) (+ \(formatCurrency(modifier)))" : name
|
||||
}
|
||||
|
||||
func applyAutoSelections() {
|
||||
if selectedSizeId != nil {
|
||||
if doughs.count == 1 { selectedDoughId = doughs.first?.id }
|
||||
else if doughs.isEmpty { selectedDoughId = "__none__" }
|
||||
}
|
||||
if isDoughReady {
|
||||
if crusts.count == 1 { selectedCrustId = crusts.first?.id }
|
||||
else if crusts.isEmpty { selectedCrustId = "__none__" }
|
||||
}
|
||||
}
|
||||
|
||||
func trimFlavorSelectionByLimit() {
|
||||
let limit = maxFlavorsAllowed
|
||||
guard selectedFlavorIds.count > limit else { return }
|
||||
selectedFlavorIds = Set(selectedFlavorIds.sorted().prefix(limit))
|
||||
}
|
||||
|
||||
func addFlavor(_ flavorId: String) {
|
||||
guard !selectedFlavorIds.contains(flavorId),
|
||||
selectedFlavorIds.count < maxFlavorsAllowed else { return }
|
||||
selectedFlavorIds.insert(flavorId)
|
||||
}
|
||||
|
||||
func removeFlavor(_ flavorId: String) {
|
||||
selectedFlavorIds.remove(flavorId)
|
||||
flavorAddonQuantities.removeValue(forKey: flavorId)
|
||||
}
|
||||
|
||||
func formatCurrency(_ value: Double) -> String {
|
||||
String(format: "R$ %.2f", value).replacingOccurrences(of: ".", with: ",")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user