Almost done

This commit is contained in:
Daniel Arantes Loverde
2026-06-08 10:46:52 -03:00
parent aec1193944
commit 23d9752bfd
25 changed files with 476 additions and 257 deletions

View File

@@ -2,7 +2,6 @@ import SwiftUI
struct AddAddressFormView: View {
@Environment(\.dismiss) var dismiss
@Environment(\.colorScheme) var colorScheme
@State var label = ""
@State var zipCode = ""
@State var address = ""
@@ -31,38 +30,18 @@ struct AddAddressFormView: View {
!state.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
}
@ViewBuilder private var logoImage: some View {
SwiftUI.Image("pedifoods")
.resizable()
}
var body: some View {
ZStack {
(colorScheme == .dark ? Color.black : AppColors.backgroundLight).ignoresSafeArea()
AppColors.backgroundLight.ignoresSafeArea()
ScrollView {
ScrollView(showsIndicators: false) {
VStack(spacing: 0) {
screenHeader(
title: existingAddress == nil ? "Novo endereço" : "Editar endereço",
onBack: { dismiss() }
)
.padding(.horizontal, 24)
.padding(.bottom, 12)
logoImage
.scaledToFit()
.frame(width: 120, height: 120)
Text(existingAddress == nil ? "Novo endereço" : "Editar endereço")
.font(AppTypography.heading1)
.foregroundStyle(colorScheme == .dark ? Color.white : AppColors.textPrimary)
Text(existingAddress == nil ? "Preencha os dados abaixo para adicionar um endereço." : "Atualize os dados do endereço abaixo.")
.font(AppTypography.body)
.foregroundStyle(colorScheme == .dark ? Color.white : AppColors.textPrimary)
.multilineTextAlignment(.center)
.padding(.top, 8)
.padding(.bottom, 20)
.padding(.horizontal, 20)
.padding(.bottom, 20)
VStack(alignment: .leading, spacing: 16) {
LoginField(icon: "tag", placeholder: "Ex: Casa, Trabalho", keyboardType: .default, text: $label)
@@ -127,7 +106,7 @@ struct AddAddressFormView: View {
Spacer().frame(height: 120)
}
}
.padding(.top, 0)
.padding(.top, 18)
}
.navigationBarBackButtonHidden(true)
.appHiddenNavigationBar()
@@ -140,8 +119,7 @@ struct AddAddressFormView: View {
ZStack {
Text(title)
.font(AppTypography.heading2)
.foregroundStyle(colorScheme == .dark ? Color.white : AppColors.textPrimary)
.foregroundStyle(AppColors.textPrimary)
HStack {
Button(action: onBack) {
Image(systemName: "chevron.left")
@@ -177,7 +155,8 @@ struct AddAddressFormView: View {
city: clean(city),
state: clean(state),
zipCode: optional(normalizeZipCodeForAPI(zipCode)),
latLong: latLong
latLong: latLong,
isDefault: existingAddress?.isDefault
)
isLoading = true
zipLookupMessage = nil

View File

@@ -118,6 +118,9 @@ struct AddressCard: View {
let item: AddressListItem
var onEdit: (() -> Void)? = nil
var onDelete: (() -> Void)? = nil
var onSetDefault: (() -> Void)? = nil
private var hasActions: Bool { onEdit != nil || onSetDefault != nil }
var body: some View {
HStack(spacing: 14) {
@@ -150,30 +153,30 @@ struct AddressCard: View {
Spacer(minLength: 8)
if onEdit != nil || onDelete != nil {
if hasActions {
Rectangle()
.fill(AppColors.backgroundLight)
.frame(width: 1, height: 96)
}
VStack(spacing: 24) {
VStack(spacing: 20) {
if let onEdit {
Button(action: onEdit) {
Image(systemName: "pencil")
.font(.system(size: 22))
.font(.system(size: 20))
.foregroundStyle(AppColors.textMuted)
}
}
if let onDelete {
Button(action: onDelete) {
Image(systemName: "trash")
.font(.system(size: 22))
if let onSetDefault, item.isPrimary == false {
Button(action: onSetDefault) {
Image(systemName: "star")
.font(.system(size: 20))
.foregroundStyle(AppColors.textMuted)
}
}
}
.frame(width: onEdit != nil || onDelete != nil ? 40 : 0)
.frame(width: hasActions ? 40 : 0)
}
.padding(.horizontal, 16)
.padding(.vertical, 20)

View File

@@ -12,6 +12,7 @@ struct AddressesView: View {
@State var editingAddress: CustomerAddress? = nil
@State var openSwipeRowId: String? = nil
@State var deletingRowId: String? = nil
@State var settingDefaultRowId: String? = nil
let tabBarClearance: CGFloat = 96
@@ -54,7 +55,9 @@ struct AddressesView: View {
} else {
ForEach(Array(addresses.enumerated()), id: \.offset) { index, address in
let rowId = addressRowId(for: address, index: index)
let isSelected: Bool = {
let isPrimary: Bool = {
if address.isDefault == true { return true }
if addresses.contains(where: { $0.isDefault == true }) { return false }
if let selectedId = appState.address.selectedId {
return address.id == selectedId
}
@@ -65,10 +68,10 @@ struct AddressesView: View {
Button {
selectAddress(address)
} label: {
AddressCard(item: addressToListItem(address, isPrimary: isSelected))
AddressCard(item: addressToListItem(address, isPrimary: isPrimary))
}
.buttonStyle(.plain)
} else {
} else if addresses.count > 1 {
SwipeToDeleteAddressRow(
rowId: rowId,
openRowId: $openSwipeRowId,
@@ -76,19 +79,30 @@ struct AddressesView: View {
onDelete: { deleteAddress(address, rowId: rowId) }
) {
AddressCard(
item: addressToListItem(address, isPrimary: isSelected),
onEdit: { beginEditing(address) }
item: addressToListItem(address, isPrimary: isPrimary),
onEdit: { beginEditing(address) },
onSetDefault: { setDefaultAddress(address, rowId: rowId) }
)
.appContentShape(Rectangle())
.onTapGesture {
if openSwipeRowId == rowId {
openSwipeRowId = nil
}
}
.simultaneousGesture(TapGesture().onEnded {
if openSwipeRowId == rowId { openSwipeRowId = nil }
})
.opacity(settingDefaultRowId == rowId ? 0.6 : 1.0)
}
.id(rowId)
.opacity(deletingRowId == rowId ? 0.6 : 1.0)
.disabled(deletingRowId != nil)
.disabled(deletingRowId != nil || settingDefaultRowId != nil)
} else {
AddressCard(
item: addressToListItem(address, isPrimary: isPrimary),
onEdit: { beginEditing(address) }
)
.overlay(alignment: .bottom) {
Text("Ao menos um endereço deve permanecer")
.font(AppTypography.caption)
.foregroundStyle(AppColors.textMuted.opacity(0.7))
.padding(.bottom, 8)
}
}
}
}
@@ -230,8 +244,65 @@ struct AddressesView: View {
return "idx:\(index):\(address.label ?? ""):\(address.address ?? ""):\(address.number ?? "")"
}
private func setDefaultAddress(_ address: CustomerAddress, rowId: String) {
guard settingDefaultRowId == nil else { return }
settingDefaultRowId = rowId
openSwipeRowId = nil
Task {
var resolvedId = address.id?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if resolvedId.isEmpty {
if let book = try? await ApiService().profile(forceRefresh: true).result?.addressBook {
await MainActor.run { addresses = book }
resolvedId = book.first {
$0.address == address.address &&
$0.number == address.number &&
$0.zipCode == address.zipCode
}?.id?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
}
}
guard resolvedId.isEmpty == false else {
await MainActor.run {
settingDefaultRowId = nil
SnackbarCenter.shared.show(title: "Não foi possível identificar o endereço.", style: .error, icon: "xmark.octagon.fill", duration: 3.5)
}
return
}
do {
let response = try await ApiService().setDefaultAddress(addressId: resolvedId)
await MainActor.run {
settingDefaultRowId = nil
if response.error == false {
addresses = addresses.map { addr in
let isTarget = (addr.id ?? "") == resolvedId
return CustomerAddress(
id: addr.id, label: addr.label, address: addr.address,
number: addr.number, complement: addr.complement,
neighborhood: addr.neighborhood, city: addr.city,
state: addr.state, zipCode: addr.zipCode,
latLong: addr.latLong, isDefault: isTarget
)
}
selectAddress(address)
SnackbarCenter.shared.show(title: "Endereço principal atualizado.", style: .success, icon: "star.fill", duration: 2.5)
} else {
SnackbarCenter.shared.show(title: response.message ?? "Não foi possível definir endereço principal.", style: .error, icon: "xmark.octagon.fill", duration: 3.0)
}
}
} catch {
await MainActor.run {
settingDefaultRowId = nil
SnackbarCenter.shared.show(title: "Erro ao atualizar endereço principal.", style: .error, icon: "xmark.octagon.fill", duration: 3.0)
}
}
}
}
private func deleteAddress(_ address: CustomerAddress, rowId: String) {
guard deletingRowId == nil else { return }
guard addresses.count > 1, deletingRowId == nil else { return }
deletingRowId = rowId
openSwipeRowId = nil
@@ -302,7 +373,7 @@ struct AddressesView: View {
do {
let service = ApiService()
let response = try await service.profile()
let response = try await service.profile(forceRefresh: true)
guard response.error == false else {
errorMessage = response.message ?? "Não foi possível carregar os endereços."
isLoading = false

View File

@@ -333,16 +333,6 @@ extension CheckoutView {
)
}
@MainActor
func loadSavedCards() async {
do {
let response = try await ApiService().listCards()
if response.error == false, let cards = response.result {
savedCards = cards
}
} catch {}
}
@MainActor
func confirmOrderWithSavedCard(cardId: String, storeId: String) async {
let payloadResult = buildCreateOrderPayload(paymentMethod: .creditCard, savedCardId: cardId)

View File

@@ -28,7 +28,6 @@ struct CheckoutView: View {
@State var pixPaymentContext: PixPaymentContext? = nil
@State var cardPaymentContext: CardPaymentContext? = nil
@State var orderTrackingContext: OrderTrackingContext? = nil
@State var savedCards: [SavedCard] = []
@State var showCardSelectionSheet = false
var paymentConfig: StorePaymentMethodsInfo? { storeInfo?.paymentMethods }
@@ -191,7 +190,6 @@ struct CheckoutView: View {
items: appState.cart.toOrderItemsPayload()
)
CardSelectionSheet(
savedCards: savedCards,
cardContext: cardContext,
appState: $appState,
onSavedCardConfirmed: { cardId in
@@ -209,11 +207,6 @@ struct CheckoutView: View {
}
)
}
.task(id: paymentMethod) {
if paymentMethod == .creditCard && useInAppPayment {
await loadSavedCards()
}
}
.navigationDestination(item: $orderTrackingContext) { context in
OrderTrackingView(orderId: context.orderId, initialShortId: context.shortId, postOrderBack: {
appState.shouldNavigateToOrders = true
@@ -957,24 +950,23 @@ struct PaymentPixView: View {
// MARK: - CardSelectionSheet
struct CardSelectionSheet: View {
let savedCards: [SavedCard]
let cardContext: CardPaymentContext
@Binding var appState: AppState
let onSavedCardConfirmed: (String) -> Void
let onOrderCreated: (String, String?) -> Void
@Environment(\.dismiss) var dismiss
@State private var cards: [SavedCard] = []
@State private var isLoading = false
@State var selectedCardId: String?
@State var isSubmitting = false
@State var showNewCardSheet = false
init(savedCards: [SavedCard], cardContext: CardPaymentContext, appState: Binding<AppState>, onSavedCardConfirmed: @escaping (String) -> Void, onOrderCreated: @escaping (String, String?) -> Void) {
self.savedCards = savedCards
init(cardContext: CardPaymentContext, appState: Binding<AppState>, onSavedCardConfirmed: @escaping (String) -> Void, onOrderCreated: @escaping (String, String?) -> Void) {
self.cardContext = cardContext
self._appState = appState
self.onSavedCardConfirmed = onSavedCardConfirmed
self.onOrderCreated = onOrderCreated
_selectedCardId = State(initialValue: savedCards.first(where: { $0.isDefault })?.id ?? savedCards.first?.id)
}
var body: some View {
@@ -1004,11 +996,15 @@ struct CardSelectionSheet: View {
ScrollView(showsIndicators: false) {
VStack(alignment: .leading, spacing: 16) {
if savedCards.isEmpty == false {
if isLoading {
ProgressView()
.frame(maxWidth: .infinity)
.padding(.top, 32)
} else if cards.isEmpty == false {
VStack(alignment: .leading, spacing: 0) {
ForEach(savedCards) { card in
ForEach(cards) { card in
savedCardRow(card)
if card.id != savedCards.last?.id {
if card.id != cards.last?.id {
Divider().padding(.horizontal, 14)
}
}
@@ -1066,6 +1062,17 @@ struct CardSelectionSheet: View {
SnackbarOverlay(center: SnackbarCenter.shared)
}
.task { await fetchCards() }
}
@MainActor
private func fetchCards() async {
isLoading = true
defer { isLoading = false }
if let result = try? await ApiService().listCards(), result.error == false, let fetched = result.result {
cards = fetched
selectedCardId = fetched.first(where: { $0.isDefault })?.id ?? fetched.first?.id
}
}
private func savedCardRow(_ card: SavedCard) -> some View {

View File

@@ -2,28 +2,39 @@ import Foundation
import SwiftUI
extension PizzaProductDetailSheet {
var stepSizes: some View {
let sizeItems = sizes
return VStack(alignment: .leading, spacing: 10) {
Text("1. Tamanho")
.font(AppTypography.heading3)
.foregroundStyle(AppColors.textPrimary)
ForEach(0..<sizeItems.count, id: \.self) { index in
sizeRow(sizeItems[index])
// 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)
}
}
}
}
.padding(12)
.background(AppColors.surface)
.clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous))
}
var stepDoughs: some View {
VStack(alignment: .leading, spacing: 10) {
Text("2. Massa")
.font(AppTypography.heading3)
.foregroundStyle(AppColors.textPrimary)
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)
@@ -36,21 +47,24 @@ extension PizzaProductDetailSheet {
isSelected: selectedDoughId == dough.id
) {
selectedDoughId = dough.id
applyAutoSelections()
withAnimation(.easeInOut(duration: 0.2)) {
expandedStep = nextStep(after: 1)
}
}
}
}
}
.padding(12)
.background(AppColors.surface)
.clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous))
}
var stepCrusts: some View {
VStack(alignment: .leading, spacing: 10) {
Text("3. Borda")
.font(AppTypography.heading3)
.foregroundStyle(AppColors.textPrimary)
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)
@@ -59,38 +73,40 @@ extension PizzaProductDetailSheet {
ForEach(crusts) { crust in
radioRow(
title: crust.name ?? "Borda",
subtitle: crust.priceModifier ?? 0 > 0 ? "+ \(formatCurrency(crust.priceModifier ?? 0))" : nil,
subtitle: (crust.priceModifier ?? 0) > 0 ? "+ \(formatCurrency(crust.priceModifier ?? 0))" : nil,
isSelected: selectedCrustId == crust.id
) {
selectedCrustId = crust.id
withAnimation(.easeInOut(duration: 0.2)) {
expandedStep = 3
}
}
}
}
}
.padding(12)
.background(AppColors.surface)
.clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous))
}
var stepFlavors: some View {
let flavorItems = flavors
return VStack(alignment: .leading, spacing: 10) {
Text("4. Sabores (\(selectedFlavorIds.count)/\(maxFlavorsAllowed))")
.font(AppTypography.heading3)
.foregroundStyle(AppColors.textPrimary)
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(0..<flavorItems.count, id: \.self) { index in
let flavor = flavorItems[index]
ForEach(flavors) { flavor in
let isSelected = selectedFlavorIds.contains(flavor.id)
let price = selectedSizeId.flatMap { flavor.pizzaPrices[$0] } ?? flavor.price
let maxReached = selectedFlavorIds.count >= maxFlavorsAllowed
let disableSwitch = isSelected == false && maxReached
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)
@@ -101,21 +117,15 @@ extension PizzaProductDetailSheet {
.foregroundStyle(AppColors.textMuted)
}
}
Spacer()
Toggle("", isOn: Binding(
get: { isSelected },
set: { value in
if value {
addFlavor(flavor.id)
} else {
removeFlavor(flavor.id)
}
if value { addFlavor(flavor.id) } else { removeFlavor(flavor.id) }
}
))
.labelsHidden()
.disabled(disableSwitch)
.disabled(!isSelected && maxReached)
}
.padding(.vertical, 2)
.appContentShape(Rectangle())
@@ -126,23 +136,90 @@ extension PizzaProductDetailSheet {
}
}
}
.padding(12)
}
// 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))
}
func sizeRow(_ size: StorePizzaSize) -> some View {
let title = size.name ?? "Tamanho"
let subtitle = "Até \(max(1, size.maxFlavors ?? 1)) sabor(es)"
let selected = selectedSizeId == size.id
// MARK: - Helpers
return radioRow(
title: title,
subtitle: subtitle,
isSelected: selected
) {
selectedSizeId = size.id
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 {
@@ -158,7 +235,6 @@ extension PizzaProductDetailSheet {
.frame(width: 10, height: 10)
}
}
VStack(alignment: .leading, spacing: 2) {
Text(title)
.font(AppTypography.body)
@@ -169,7 +245,6 @@ extension PizzaProductDetailSheet {
.foregroundStyle(AppColors.textMuted)
}
}
Spacer()
}
.appContentShape(Rectangle())
@@ -181,40 +256,29 @@ extension PizzaProductDetailSheet {
guard let crust else { return "Sem borda especial" }
let name = crust.name ?? "Borda"
let modifier = crust.priceModifier ?? 0
if modifier > 0 {
return "\(name) (+ \(formatCurrency(modifier)))"
}
return name
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 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__"
}
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 }
let sorted = selectedFlavorIds.sorted()
selectedFlavorIds = Set(sorted.prefix(limit))
selectedFlavorIds = Set(selectedFlavorIds.sorted().prefix(limit))
}
func addFlavor(_ flavorId: String) {
if selectedFlavorIds.contains(flavorId) { return }
if selectedFlavorIds.count >= maxFlavorsAllowed { return }
guard !selectedFlavorIds.contains(flavorId),
selectedFlavorIds.count < maxFlavorsAllowed else { return }
selectedFlavorIds.insert(flavorId)
}

View File

@@ -17,6 +17,7 @@ struct PizzaProductDetailSheet: View {
@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
@@ -45,7 +46,7 @@ struct PizzaProductDetailSheet: View {
return resolveImageURL(firstImage)
}
private var selectedSize: StorePizzaSize? {
var selectedSize: StorePizzaSize? {
guard let selectedSizeId else { return nil }
return sizes.first(where: { $0.id == selectedSizeId })
}
@@ -76,7 +77,7 @@ struct PizzaProductDetailSheet: View {
isCrustReady
}
private var selectedFlavorProducts: [StoreCatalogProduct] {
var selectedFlavorProducts: [StoreCatalogProduct] {
flavors
.filter { selectedFlavorIds.contains($0.id) }
.sorted { $0.name < $1.name }
@@ -102,12 +103,12 @@ struct PizzaProductDetailSheet: View {
}
private var basePizzaPrice: Double {
selectedFlavorProducts
.map { flavor in
guard let selectedSizeId else { return flavor.price ?? 0 }
return flavor.pizzaPrices[selectedSizeId] ?? flavor.price ?? 0
}
.max() ?? 0
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 {
@@ -179,6 +180,34 @@ struct PizzaProductDetailSheet: View {
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"
@@ -190,8 +219,16 @@ struct PizzaProductDetailSheet: View {
ScrollView(showsIndicators: false) {
VStack(alignment: .leading, spacing: 16) {
screenHeader
AsyncStoreImage(imageURL: representativeImage)
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("Pizza de varios sabores")
@@ -207,18 +244,9 @@ struct PizzaProductDetailSheet: View {
.foregroundStyle(AppColors.primary)
stepSizes
if selectedSizeId != nil {
stepDoughs
}
if isDoughReady {
stepCrusts
}
if canShowFlavors {
stepFlavors
}
if selectedSizeId != nil { stepDoughs }
if isDoughReady { stepCrusts }
if canShowFlavors { stepFlavors }
}
.padding(20)
.padding(.bottom, 90)
@@ -266,6 +294,7 @@ struct PizzaProductDetailSheet: View {
name: "Pizza de varios sabores",
imageURL: representativeImage,
details: selectedDetailsText,
choices: pizzaChoices.isEmpty ? nil : pizzaChoices,
addons: selectedAddonsPayload,
quantity: quantity,
unitPrice: unitPrice

View File

@@ -56,12 +56,14 @@ struct ProfileView: View {
}
.buttonStyle(.plain)
// NavigationLink {
// Text("Cupons de Desconto")
// } label: {
// ProfileMenuRow(icon: "ticket.fill", title: "Cupons de Desconto", badge: "NOVO")
// }
// .buttonStyle(.plain)
if appState.featureFlags.isEnabled("at.cupons") {
NavigationLink {
Text("Cupons de Desconto")
} label: {
ProfileMenuRow(icon: "ticket.fill", title: "Cupons de Desconto", badge: "NOVO")
}
.buttonStyle(.plain)
}
// NavigationLink {
// Text("Ajuda")

View File

@@ -848,6 +848,7 @@ struct OrderReviewView: View {
.frame(height: 110)
.padding(.horizontal, 8)
.padding(.vertical, 8)
.foregroundStyle(AppColors.textPrimary)
.scrollContentBackground(.hidden)
.background(AppColors.surface)
.clipShape(RoundedRectangle(cornerRadius: 20, style: .continuous))

View File

@@ -47,6 +47,7 @@ struct ScrollOffsetReader: View {
struct AsyncStoreImage: View {
let imageURL: String?
var fallbackImageName: String = "placeholder-product"
var body: some View {
CachedRemoteImage(imageURL: imageURL) {
@@ -58,7 +59,7 @@ struct AsyncStoreImage: View {
}
private var fallback: some View {
Image("placeholder-product")
Image(fallbackImageName)
.resizable()
.scaledToFill()
}

View File

@@ -269,9 +269,17 @@ extension StoreDetailView {
Spacer()
ZStack(alignment: .bottomTrailing) {
AsyncStoreImage(imageURL: resolvedURL(item.imageURL))
.frame(width: 92, height: 92)
.clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous))
Group {
if item.isPizzaSummary {
Image("placeholder-pizza")
.resizable()
.scaledToFill()
} else {
AsyncStoreImage(imageURL: resolvedURL(item.imageURL))
}
}
.frame(width: 92, height: 92)
.clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous))
Button {
guard isStoreOpen else {

View File

@@ -297,8 +297,9 @@ struct UserProfileView: View {
guard let item else { return }
do {
guard let data = try await item.loadTransferable(type: Data.self) else { return }
guard let image = UIImage(data: data),
let jpegData = image.jpegData(compressionQuality: 0.82) else { return }
guard let image = UIImage(data: data) else { return }
let resized = resizedIfNeeded(image, maxSide: 600)
guard let jpegData = resized.jpegData(compressionQuality: 0.8) else { return }
profilePicture = "data:image/jpeg;base64,\(jpegData.base64EncodedString())"
} catch {
SnackbarCenter.shared.show(
@@ -309,5 +310,17 @@ struct UserProfileView: View {
)
}
}
private func resizedIfNeeded(_ image: UIImage, maxSide: CGFloat) -> UIImage {
let w = image.size.width
let h = image.size.height
guard w > maxSide || h > maxSide else { return image }
let scale = maxSide / max(w, h)
let newSize = CGSize(width: w * scale, height: h * scale)
let renderer = UIGraphicsImageRenderer(size: newSize)
return renderer.image { _ in
image.draw(in: CGRect(origin: .zero, size: newSize))
}
}
#endif
}