This commit is contained in:
Daniel Arantes Loverde
2026-07-07 15:11:31 -03:00
parent 8b9ebdcb44
commit e51c99973f
293 changed files with 457 additions and 4919 deletions

View File

@@ -0,0 +1,295 @@
import Foundation
import SwiftUI
struct ProductDetailSheet: View {
let product: StoreCatalogProduct
let imageURL: String?
let storeId: String
let currentQuantityForItemId: (String) -> Int
let onAdd: (CartItemState) -> Void
@Environment(\.dismiss) var dismiss
@State var selectedAddonQuantities: [String: Int] = [:]
@State var quantity: Int = 0
private var addonItemsById: [String: StoreAddonItem] {
Dictionary(uniqueKeysWithValues: product.addonGroups.flatMap(\.items).map { ($0.id, $0) })
}
private var selectedAddonItems: [(item: StoreAddonItem, quantity: Int)] {
selectedAddonQuantities
.compactMap { key, qty in
guard qty > 0, let item = addonItemsById[key] else { return nil }
return (item, qty)
}
.sorted { $0.item.name < $1.item.name }
}
private var addonsTotal: Double {
selectedAddonItems.reduce(0) { partial, pair in
partial + (Double(pair.quantity) * (pair.item.price ?? 0))
}
}
private var unitPrice: Double {
(product.price ?? 0) + addonsTotal
}
private var totalPrice: Double {
unitPrice * Double(quantity)
}
private var cartItemId: String {
let addonKey = encodedAddonKey
return "\(storeId)::\(product.id)::\(addonKey)"
}
private var selectedAddonsSummary: String? {
let names = selectedAddonItems.map { pair in
pair.quantity > 1 ? "\(pair.item.name) x\(pair.quantity)" : pair.item.name
}
if names.isEmpty { return nil }
return names.joined(separator: ", ")
}
private var selectedAddonsPayload: [CartItemAddonState] {
selectedAddonItems.map { pair in
CartItemAddonState(
id: pair.item.id,
name: pair.item.name,
quantity: pair.quantity,
unitPrice: pair.item.price ?? 0
)
}
}
private var addButtonTitle: String {
if quantity <= 0 {
return "Remover do carrinho"
}
return "Atualizar • \(formatCurrency(totalPrice))"
}
var body: some View {
ScrollView(showsIndicators: false) {
VStack(alignment: .leading, spacing: 16) {
screenHeader
AsyncStoreImage(imageURL: imageURL)
.frame(height: 220)
.clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusXL, style: .continuous))
Text(product.name)
.font(AppTypography.heading2)
.foregroundStyle(AppColors.textPrimary)
if let description = product.description, description.isEmpty == false {
Text(description)
.font(AppTypography.body)
.foregroundStyle(AppColors.textMuted)
}
Text(formatCurrency(unitPrice))
.font(AppTypography.heading2)
.foregroundStyle(AppColors.primary)
if addonsTotal > 0 {
Text("Inclui adicionais: \(formatCurrency(addonsTotal))")
.font(.caption)
.foregroundStyle(AppColors.textMuted)
} else {
Text("Sem adicionais")
.font(.caption)
.foregroundStyle(AppColors.textMuted)
}
if product.addonGroups.isEmpty == false {
Text("Adicionais")
.font(AppTypography.heading3)
.foregroundStyle(AppColors.textPrimary)
ForEach(product.addonGroups) { group in
VStack(alignment: .leading, spacing: 8) {
Text(group.name)
.font(AppTypography.heading3)
.foregroundStyle(AppColors.textPrimary)
ForEach(group.items) { item in
HStack(spacing: 10) {
VStack(alignment: .leading, spacing: 4) {
Text(item.name)
.font(AppTypography.body)
.foregroundStyle(AppColors.textMuted)
Text(String(format: "+ R$ %.2f", item.price ?? 0).replacingOccurrences(of: ".", with: ","))
.font(.caption)
.foregroundStyle(AppColors.textMuted)
}
Spacer()
HStack(spacing: 8) {
Button(action: { decrementAddon(item.id) }) {
Image(systemName: "minus")
.font(.system(size: 12, weight: .bold))
.foregroundStyle(AppColors.textPrimary)
.frame(width: 26, height: 26)
.background(AppColors.brandSoft)
.clipShape(Circle())
}
.buttonStyle(.plain)
.disabled(quantity(forAddonId: item.id) <= 0 || quantity <= 0)
Text("\(quantity(forAddonId: item.id))")
.font(AppTypography.heading3)
.foregroundStyle(AppColors.textPrimary)
.frame(minWidth: 18)
Button(action: { incrementAddon(item.id) }) {
Image(systemName: "plus")
.font(.system(size: 12, weight: .bold))
.foregroundStyle(AppColors.textPrimary)
.frame(width: 26, height: 26)
.background(AppColors.tertiary)
.clipShape(Circle())
}
.buttonStyle(.plain)
.disabled(quantity <= 0)
}
}
}
}
.padding(12)
.background(AppColors.surface)
.clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous))
}
}
}
.padding(20)
.padding(.bottom, 80)
}
.appBottomSafeAreaInset {
HStack(spacing: 12) {
HStack(spacing: 10) {
Button(action: { if quantity > 0 { 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 <= 0)
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))
.buttonStyle(.plain)
PrimaryButton(title: addButtonTitle) {
let item = CartItemState(
id: cartItemId,
productId: product.id,
storeId: storeId,
name: product.name,
imageURL: imageURL,
details: selectedAddonsSummary,
addons: selectedAddonsPayload,
quantity: quantity,
unitPrice: unitPrice
)
onAdd(item)
dismiss()
}
}
.padding(.horizontal, 20)
.padding(.top, 8)
.padding(.bottom, 12)
.background(.ultraThinMaterial)
}
.background(AppColors.backgroundLight.ignoresSafeArea())
.appHiddenNavigationBar()
.navigationBarBackButtonHidden(true)
.onAppear {
let existing = currentQuantityForItemId(cartItemId)
quantity = existing > 0 ? existing : 1
}
.onChange(of: selectedAddonQuantities) { _, _ in
// Keep the main quantity stable when changing addon quantities.
// Only hydrate from cart if this exact configuration already exists.
let existingQuantity = currentQuantityForItemId(cartItemId)
if existingQuantity > 0 {
quantity = existingQuantity
}
}
.onChange(of: quantity) { _, newValue in
if newValue <= 0 {
selectedAddonQuantities.removeAll()
}
}
}
private var screenHeader: some View {
ZStack {
Text("Detalhes")
.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()
}
}
}
private func formatCurrency(_ value: Double) -> String {
String(format: "R$ %.2f", value).replacingOccurrences(of: ".", with: ",")
}
private var encodedAddonKey: String {
let tokens = selectedAddonQuantities
.filter { $0.value > 0 }
.map { "\($0.key):\($0.value)" }
.sorted()
return tokens.isEmpty ? "base" : tokens.joined(separator: ",")
}
private func quantity(forAddonId addonId: String) -> Int {
selectedAddonQuantities[addonId] ?? 0
}
private func incrementAddon(_ addonId: String) {
selectedAddonQuantities[addonId, default: 0] += 1
}
private func decrementAddon(_ addonId: String) {
let current = selectedAddonQuantities[addonId] ?? 0
if current <= 1 {
selectedAddonQuantities.removeValue(forKey: addonId)
} else {
selectedAddonQuantities[addonId] = current - 1
}
}
}