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,254 @@
import SwiftUI
struct AddAddressFormView: View {
@Environment(\.dismiss) var dismiss
@State var label = ""
@State var zipCode = ""
@State var address = ""
@State var number = ""
@State var complement = ""
@State var neighborhood = ""
@State var city = ""
@State var state = ""
@State var isLoading = false
@State var isLookingUpZipCode = false
@State var zipLookupMessage: String? = nil
@State var lastLookedUpZipCode = ""
@State var lookedUpLatitude: Double? = nil
@State var lookedUpLongitude: Double? = nil
let existingAddress: CustomerAddress?
let onSave: ([CustomerAddress], Bool) -> Void
private var isFormValid: Bool {
!label.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty &&
normalizeZipCodeForAPI(zipCode).count == 8 &&
!address.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty &&
!number.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty &&
!neighborhood.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty &&
!city.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty &&
!state.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
}
var body: some View {
ZStack {
AppColors.backgroundLight.ignoresSafeArea()
ScrollView(showsIndicators: false) {
VStack(spacing: 0) {
screenHeader(
title: existingAddress == nil ? "Novo endereço" : "Editar endereço",
onBack: { dismiss() }
)
.padding(.horizontal, 20)
.padding(.bottom, 20)
VStack(alignment: .leading, spacing: 16) {
LoginField(icon: "tag", placeholder: "Ex: Casa, Trabalho", keyboardType: .default, text: $label)
LoginField(icon: "mail", placeholder: "CEP", keyboardType: .numberPad, text: $zipCode)
.onChange(of: zipCode) { _, newValue in
let masked = formatZipCodeBR(newValue)
if masked != newValue {
zipCode = masked
}
let normalized = normalizeZipCodeForAPI(masked)
if normalized.count == 8, normalized != lastLookedUpZipCode, !isLookingUpZipCode {
Task {
await lookupAddressByZipCode(normalized)
}
}
}
if isLookingUpZipCode {
HStack(spacing: 8) {
ProgressView()
Text("Buscando endereço pelo CEP...")
.font(.caption)
.foregroundStyle(AppColors.textMuted)
}
.padding(.horizontal, 6)
} else if let zipLookupMessage {
Text(zipLookupMessage)
.font(.caption)
.foregroundStyle(AppColors.textMuted)
.padding(.horizontal, 6)
}
LoginField(icon: "mappin.and.ellipse", placeholder: "Rua / Avenida", keyboardType: .default, text: $address)
LoginField(icon: "number", placeholder: "Número", keyboardType: .default, text: $number)
LoginField(icon: "plus.app", placeholder: "Complemento (opcional)", keyboardType: .default, text: $complement)
LoginField(icon: "square.grid.2x2", placeholder: "Bairro", keyboardType: .default, text: $neighborhood)
LoginField(icon: "building.2", placeholder: "Cidade", keyboardType: .default, text: $city)
LoginField(icon: "map", placeholder: "Estado (UF)", keyboardType: .default, text: $state)
.onChange(of: state) { _, newValue in
let normalized = String(newValue.uppercased().prefix(2))
if normalized != newValue {
state = normalized
}
}
}
.padding(.horizontal, 24)
.padding(.bottom, 20)
PrimaryButton(title: existingAddress == nil ? "Salvar endereço" : "Atualizar endereço", image: Image(systemName: "checkmark")) {
saveAddress()
}
.padding(.horizontal, 24)
.disabled(!isFormValid || isLoading)
.opacity((!isFormValid || isLoading) ? 0.5 : 1.0)
.tint(AppColors.tertiary)
SecondaryButton(title: "Cancelar") {
dismiss()
}
.padding(.horizontal, 24)
.padding(.top, 12)
Spacer().frame(height: 120)
}
}
.padding(.top, 18)
}
.navigationBarBackButtonHidden(true)
.appHiddenNavigationBar()
.onAppear {
populateFromExistingAddressIfNeeded()
}
}
private func screenHeader(title: String, onBack: @escaping () -> Void) -> some View {
ZStack {
Text(title)
.font(AppTypography.heading2)
.foregroundStyle(AppColors.textPrimary)
HStack {
Button(action: onBack) {
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 saveAddress() {
guard !isLoading else { return }
let latLong: [Double]? = {
if let lat = lookedUpLatitude, let lng = lookedUpLongitude {
return [lat, lng]
}
return nil
}()
let newAddress = CustomerAddress(
id: existingAddress?.id ?? UUID().uuidString,
label: clean(label),
address: clean(address),
number: clean(number),
complement: optional(clean(complement)),
neighborhood: clean(neighborhood),
city: clean(city),
state: clean(state),
zipCode: optional(normalizeZipCodeForAPI(zipCode)),
latLong: latLong,
isDefault: existingAddress?.isDefault
)
isLoading = true
zipLookupMessage = nil
Task {
do {
let response = try await ApiService().saveCustomerAddress(newAddress, replacingAddressId: existingAddress?.id)
await MainActor.run {
isLoading = false
if response.error {
zipLookupMessage = response.message ?? "Não foi possível salvar o endereço."
SnackbarCenter.shared.show(title: zipLookupMessage ?? "Não foi possível salvar o endereço.", style: .error, icon: "xmark.octagon.fill", duration: 4.0)
return
}
let updatedAddresses = response.result?.addressBook ?? [newAddress]
onSave(updatedAddresses, existingAddress != nil)
dismiss()
}
} catch {
await MainActor.run {
isLoading = false
let message = error.localizedDescription
zipLookupMessage = message
SnackbarCenter.shared.show(title: message, style: .error, icon: "xmark.octagon.fill", duration: 4.0)
}
}
}
}
private func populateFromExistingAddressIfNeeded() {
guard let existingAddress else { return }
label = existingAddress.label ?? ""
zipCode = formatZipCodeBR(existingAddress.zipCode ?? "")
lastLookedUpZipCode = normalizeZipCodeForAPI(zipCode)
address = existingAddress.address ?? ""
number = existingAddress.number ?? ""
complement = existingAddress.complement ?? ""
neighborhood = existingAddress.neighborhood ?? ""
city = existingAddress.city ?? ""
state = String((existingAddress.state ?? "").uppercased().prefix(2))
lookedUpLatitude = existingAddress.latLong?.first
lookedUpLongitude = existingAddress.latLong?.dropFirst().first
}
private func clean(_ value: String) -> String {
value.trimmingCharacters(in: .whitespacesAndNewlines)
}
private func optional(_ value: String) -> String? {
value.isEmpty ? nil : value
}
@MainActor
private func lookupAddressByZipCode(_ zip: String) async {
isLookingUpZipCode = true
zipLookupMessage = nil
defer { isLookingUpZipCode = false }
do {
let response = try await ApiService().lookupZipCode(zip)
lastLookedUpZipCode = zip
guard response.error == false, let result = response.result else {
zipLookupMessage = response.message ?? "Não foi possível consultar este CEP."
return
}
fillAddressFields(with: result)
zipLookupMessage = "Endereço preenchido automaticamente."
} catch {
zipLookupMessage = "Não foi possível consultar o CEP agora."
}
}
private func fillAddressFields(with result: CepLookupResult) {
if address.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
address = result.street?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
}
if neighborhood.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
neighborhood = result.neighborhood?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
}
if city.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
city = result.city?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
}
if state.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
state = String((result.state ?? "").uppercased().prefix(2))
}
if complement.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
complement = result.complement?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
}
lookedUpLatitude = result.latitude
lookedUpLongitude = result.longitude
}
}

View File

@@ -0,0 +1,461 @@
import SwiftUI
struct AddCardFormView: View {
let appState: AppState
let isFirstCard: Bool
let onCardAdded: (SavedCard) -> Void
@Environment(\.dismiss) var dismiss
@State private var cardNumber = ""
@State private var holderName = ""
@State private var expiry = ""
@State private var cvv = ""
@State private var cpf = ""
@State private var nickname = ""
@State private var isDefault = false
@State private var isSaving = false
@State private var addresses: [CustomerAddress] = []
@State private var selectedAddress: CustomerAddress? = nil
@State private var isLoadingAddresses = false
@State private var showAddressPicker = false
private var detectedBrandLogo: String? {
let clean = cardNumber.filter(\.isNumber)
guard clean.isEmpty == false else { return nil }
if clean.hasPrefix("506766") || clean.hasPrefix("603389") { return "sodexo_logo" }
if clean.hasPrefix("5067") || clean.hasPrefix("5078") || clean.hasPrefix("6278") || clean.hasPrefix("6367") { return "alelocard_logo" }
if clean.hasPrefix("3841") || clean.hasPrefix("606282") || clean.hasPrefix("637095") || clean.hasPrefix("637568") { return "hipercard_logo" }
if clean.hasPrefix("34") || clean.hasPrefix("37") { return "amexcard_logo" }
if clean.hasPrefix("4") { return "visacard_logo" }
let prefix2 = Int(clean.prefix(2)) ?? 0
if (51...59).contains(prefix2) { return "mastercard_logo" }
if let p4 = Int(clean.prefix(4)), (2221...2720).contains(p4) { return "mastercard_logo" }
return nil
}
private var selectedAddressZip: String {
(selectedAddress?.zipCode ?? "").filter(\.isNumber)
}
private var canSave: Bool {
let digits = cardNumber.filter(\.isNumber)
let cpfDigits = cpf.filter(\.isNumber)
let parts = expiry.split(separator: "/")
return digits.count >= 13
&& holderName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false
&& parts.count == 2
&& cvv.count >= 3
&& cpfDigits.count == 11
&& selectedAddress != nil
&& selectedAddressZip.count >= 7
}
var body: some View {
ScrollView(showsIndicators: false) {
VStack(spacing: 20) {
screenHeader
formSection("Dados do Cartão") {
cardNumberField
labeledField("Nome no cartão", placeholder: "Como impresso no cartão", text: $holderName, autocap: true)
HStack(spacing: 12) {
labeledField("Validade", placeholder: "MM/AA", text: $expiry, keyboard: .numberPad)
.onChange(of: expiry) { _, v in expiry = formatExpiry(v) }
labeledField("CVV", placeholder: "•••", text: $cvv, keyboard: .numberPad)
.onChange(of: cvv) { _, v in cvv = String(v.filter(\.isNumber).prefix(4)) }
}
}
formSection("Identificação do Titular") {
labeledField("CPF", placeholder: "000.000.000-00", text: $cpf, keyboard: .numberPad)
.onChange(of: cpf) { _, v in cpf = formatCPF(v.filter(\.isNumber)) }
addressPickerRow
}
formSection("Opções") {
labeledField("Apelido (opcional)", placeholder: "Ex: Cartão do Nubank", text: $nickname)
if isFirstCard == false {
Toggle(isOn: $isDefault) {
Text("Definir como principal")
.font(AppTypography.heading3)
.foregroundStyle(AppColors.textPrimary)
}
.tint(AppColors.primary)
.padding(14)
.background(AppColors.surface)
.clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous))
}
}
Button(action: { Task { await saveCard() } }) {
Group {
if isSaving {
ProgressView().tint(Color(hex: "#0E1A06"))
} else {
Text("Salvar Cartão").font(AppTypography.heading2)
}
}
.foregroundStyle(Color(hex: "#0E1A06"))
.frame(maxWidth: .infinity, minHeight: 56)
.background(canSave && !isSaving ? Color(hex: "#C8F06E") : Color(hex: "#C8F06E").opacity(0.45))
.clipShape(Capsule())
}
.buttonStyle(.plain)
.disabled(!canSave || isSaving)
.padding(.top, 4)
}
.padding(.horizontal, 20)
.padding(.top, 18)
.padding(.bottom, 40)
}
.background(AppColors.backgroundLight)
.navigationBarBackButtonHidden(true)
.appHiddenNavigationBar()
.sheet(isPresented: $showAddressPicker) {
addressPickerSheet
}
.task { await loadData() }
}
// MARK: - Address picker row
private var addressPickerRow: some View {
VStack(alignment: .leading, spacing: 6) {
Text("Endereço de cobrança")
.font(AppTypography.caption)
.foregroundStyle(AppColors.textMuted)
Button {
if addresses.isEmpty == false { showAddressPicker = true }
} label: {
HStack(spacing: 10) {
Image(systemName: "mappin.circle.fill")
.font(.system(size: 18))
.foregroundStyle(selectedAddress != nil ? AppColors.primary : AppColors.textMuted)
VStack(alignment: .leading, spacing: 2) {
if isLoadingAddresses {
Text("Carregando endereços...")
.font(AppTypography.body)
.foregroundStyle(AppColors.textMuted)
} else if let addr = selectedAddress {
Text(addressDisplayTitle(addr))
.font(AppTypography.heading3)
.foregroundStyle(AppColors.textPrimary)
.lineLimit(1)
if let sub = addressDisplaySubtitle(addr) {
Text(sub)
.font(AppTypography.caption)
.foregroundStyle(AppColors.textMuted)
.lineLimit(1)
}
} else if addresses.isEmpty {
Text("Nenhum endereço cadastrado")
.font(AppTypography.body)
.foregroundStyle(AppColors.textMuted)
} else {
Text("Selecionar endereço")
.font(AppTypography.body)
.foregroundStyle(AppColors.textMuted)
}
}
Spacer()
if addresses.isEmpty == false {
Image(systemName: "chevron.right")
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(AppColors.textMuted)
}
}
.padding(14)
.background(AppColors.surface)
.clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous))
}
.buttonStyle(.plain)
.disabled(isLoadingAddresses || addresses.isEmpty)
}
}
// MARK: - Address picker sheet
private var addressPickerSheet: some View {
NavigationStack {
ScrollView(showsIndicators: false) {
VStack(spacing: 10) {
ForEach(Array(addresses.enumerated()), id: \.offset) { _, addr in
Button {
selectedAddress = addr
showAddressPicker = false
} label: {
HStack(spacing: 12) {
Image(systemName: "mappin.circle.fill")
.font(.system(size: 22))
.foregroundStyle(isSelected(addr) ? AppColors.primary : AppColors.textMuted)
VStack(alignment: .leading, spacing: 3) {
Text(addressDisplayTitle(addr))
.font(AppTypography.heading3)
.foregroundStyle(AppColors.textPrimary)
.lineLimit(1)
if let sub = addressDisplaySubtitle(addr) {
Text(sub)
.font(AppTypography.caption)
.foregroundStyle(AppColors.textMuted)
.lineLimit(1)
}
}
Spacer()
if isSelected(addr) {
Image(systemName: "checkmark.circle.fill")
.foregroundStyle(AppColors.primary)
}
}
.padding(14)
.background(AppColors.surface)
.clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous))
}
.buttonStyle(.plain)
}
}
.padding(.horizontal, 20)
.padding(.top, 14)
.padding(.bottom, 30)
}
.background(AppColors.backgroundLight)
.navigationTitle("Endereço de cobrança")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Fechar") { showAddressPicker = false }
.foregroundStyle(AppColors.textPrimary)
}
}
}
}
// MARK: - Sub-views
private var screenHeader: some View {
ZStack {
Text("Novo Cartão")
.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 var cardNumberField: some View {
VStack(alignment: .leading, spacing: 6) {
Text("Número do cartão")
.font(AppTypography.caption)
.foregroundStyle(AppColors.textMuted)
HStack(spacing: 8) {
TextField("0000 0000 0000 0000", text: $cardNumber)
.font(AppTypography.heading3)
.foregroundStyle(AppColors.textPrimary)
.keyboardType(.numberPad)
.onChange(of: cardNumber) { _, v in cardNumber = formatCardNumber(v.filter(\.isNumber)) }
let digits = cardNumber.filter(\.isNumber)
if let logo = detectedBrandLogo {
Image(logo)
.resizable()
.scaledToFit()
.frame(width: 40, height: 26)
} else if digits.count >= 4 {
Image(systemName: "creditcard")
.font(.system(size: 22))
.foregroundStyle(AppColors.textMuted)
.frame(width: 40, height: 26)
}
}
.padding(14)
.background(AppColors.surface)
.clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous))
}
}
private func labeledField(
_ label: String,
placeholder: String,
text: Binding<String>,
keyboard: UIKeyboardType = .default,
autocap: Bool = false
) -> some View {
VStack(alignment: .leading, spacing: 6) {
Text(label)
.font(AppTypography.caption)
.foregroundStyle(AppColors.textMuted)
TextField(placeholder, text: text)
.font(AppTypography.heading3)
.foregroundStyle(AppColors.textPrimary)
.keyboardType(keyboard)
.autocorrectionDisabled()
.textInputAutocapitalization(autocap ? .characters : .never)
.padding(14)
.background(AppColors.surface)
.clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous))
}
}
private func formSection(_ title: String, @ViewBuilder content: () -> some View) -> some View {
VStack(alignment: .leading, spacing: 10) {
Text(title)
.font(AppTypography.caption)
.foregroundStyle(AppColors.textMuted)
.padding(.leading, 2)
content()
}
}
// MARK: - Helpers
private func isSelected(_ addr: CustomerAddress) -> Bool {
guard let sel = selectedAddress else { return false }
if let id = addr.id, let selId = sel.id { return id == selId }
return addr.address == sel.address && addr.number == sel.number
}
private func addressDisplayTitle(_ addr: CustomerAddress) -> String {
let label = addr.label?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if label.isEmpty == false { return label }
let street = addr.address?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let number = addr.number?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let base = [street, number].filter { $0.isEmpty == false }.joined(separator: ", ")
return base.isEmpty ? "Endereço" : base
}
private func addressDisplaySubtitle(_ addr: CustomerAddress) -> String? {
let parts = [
addr.neighborhood?.trimmingCharacters(in: .whitespacesAndNewlines),
addr.city?.trimmingCharacters(in: .whitespacesAndNewlines),
addr.state?.trimmingCharacters(in: .whitespacesAndNewlines)
].compactMap { v -> String? in
guard let v, v.isEmpty == false else { return nil }
return v
}
return parts.isEmpty ? nil : parts.joined(separator: ", ")
}
// MARK: - Load & Save
@MainActor
private func loadData() async {
holderName = appState.profile.name
cpf = formatCPF(appState.profile.cpf.filter(\.isNumber))
isDefault = isFirstCard
isLoadingAddresses = true
defer { isLoadingAddresses = false }
if let result = try? await ApiService().profile(forceRefresh: false).result {
let book = result.addressBook ?? []
addresses = book
selectedAddress = book.first
}
}
@MainActor
private func saveCard() async {
guard canSave, let addr = selectedAddress else { return }
isSaving = true
defer { isSaving = false }
let parts = expiry.split(separator: "/")
let month = String(parts[0])
let year: String = {
let y = String(parts[1])
return y.count == 2 ? "20\(y)" : y
}()
let cleanNumber = cardNumber.filter(\.isNumber)
let cleanCpf = cpf.filter(\.isNumber)
let zip = selectedAddressZip
let addrNumber = addr.number?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "0"
let creditCard = SaveCardCreditCardPayload(
holderName: holderName.trimmingCharacters(in: .whitespacesAndNewlines).uppercased(),
number: cleanNumber,
expiryMonth: month,
expiryYear: year,
ccv: cvv
)
let holderInfo = SaveCardHolderInfoPayload(
name: holderName.trimmingCharacters(in: .whitespacesAndNewlines),
email: appState.profile.email,
cpfCnpj: cleanCpf,
postalCode: zip,
addressNumber: addrNumber.isEmpty ? "0" : addrNumber,
phone: appState.profile.phone
)
let payload = SaveCardPayload(
creditCard: creditCard,
creditCardHolderInfo: holderInfo,
nickname: nickname.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? nil : nickname,
isDefault: isDefault || isFirstCard
)
do {
let response = try await ApiService().saveCard(payload: payload)
if response.error == false, let result = response.result {
let newCard = SavedCard(
id: result.id,
nickname: payload.nickname,
holderName: result.holderName,
last4: result.last4,
brand: result.brand,
expiryMonth: result.expiryMonth,
expiryYear: result.expiryYear,
isDefault: result.isDefault
)
onCardAdded(newCard)
dismiss()
SnackbarCenter.shared.show(title: "Cartão salvo com sucesso.", style: .success, icon: "creditcard.fill", duration: 2.5)
} else {
SnackbarCenter.shared.show(title: response.message ?? "Não foi possível salvar o cartão.", style: .error, icon: "xmark.octagon.fill", duration: 3.5)
}
} catch {
SnackbarCenter.shared.show(title: "Erro ao salvar cartão. Verifique os dados e tente novamente.", style: .error, icon: "xmark.octagon.fill", duration: 3.5)
}
}
// MARK: - Formatters
private func formatCardNumber(_ digits: String) -> String {
let d = String(digits.prefix(16))
var result = ""
for (i, c) in d.enumerated() {
if i > 0 && i % 4 == 0 { result += " " }
result.append(c)
}
return result
}
private func formatExpiry(_ value: String) -> String {
let digits = String(value.filter(\.isNumber).prefix(4))
if digits.count > 2 { return "\(digits.prefix(2))/\(digits.dropFirst(2))" }
return digits
}
private func formatCPF(_ digits: String) -> String {
let d = String(digits.prefix(11))
if d.count <= 3 { return d }
if d.count <= 6 { return "\(d.prefix(3)).\(d.dropFirst(3))" }
if d.count <= 9 { return "\(d.prefix(3)).\(d.dropFirst(3).prefix(3)).\(d.dropFirst(6))" }
return "\(d.prefix(3)).\(d.dropFirst(3).prefix(3)).\(d.dropFirst(6).prefix(3))-\(d.dropFirst(9))"
}
}

View File

@@ -0,0 +1,197 @@
import SwiftUI
import UIKit
func formatZipCodeBR(_ input: String) -> String {
let digits = input.filter(\.isNumber)
let limited = String(digits.prefix(8))
if limited.count <= 5 {
return limited
}
let prefix = String(limited.prefix(5))
let suffix = String(limited.dropFirst(5))
return "\(prefix)-\(suffix)"
}
func normalizeZipCodeForAPI(_ input: String) -> String {
String(input.filter(\.isNumber).prefix(8))
}
func triggerLightHaptic() {
UIImpactFeedbackGenerator(style: .light).impactOccurred()
}
func triggerSelectionHaptic() {
UISelectionFeedbackGenerator().selectionChanged()
}
struct SwipeToDeleteAddressRow<Content: View>: View {
let rowId: String
@Binding var openRowId: String?
let isDeleting: Bool
let onDelete: () -> Void
@ViewBuilder var content: () -> Content
@State var contentOffset: CGFloat = 0
private let deleteWidth: CGFloat = 92
private let openThreshold: CGFloat = 32
private var showsDeleteAction: Bool { contentOffset < -2 || openRowId == rowId }
var body: some View {
ZStack(alignment: .trailing) {
HStack(spacing: 0) {
Spacer(minLength: 0)
Button(action: {
triggerLightHaptic()
onDelete()
}) {
VStack(spacing: 8) {
Image(systemName: "trash.fill")
.font(.system(size: 20, weight: .semibold))
Text(isDeleting ? "..." : "Excluir")
.font(AppTypography.overline)
}
.foregroundStyle(AppColors.textInverse)
.frame(width: deleteWidth)
.frame(maxHeight: .infinity)
.background(Color.red)
}
.buttonStyle(.plain)
.disabled(isDeleting)
.opacity(showsDeleteAction ? 1 : 0)
.clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusXL, style: .continuous))
}
content()
.offset(x: contentOffset)
.gesture(
DragGesture(minimumDistance: 8)
.onChanged { value in
guard isDeleting == false else { return }
if value.translation.width < 0 {
contentOffset = max(-deleteWidth, value.translation.width)
} else if openRowId == rowId {
contentOffset = min(0, -deleteWidth + value.translation.width)
}
}
.onEnded { _ in
guard isDeleting == false else { return }
if contentOffset <= -openThreshold {
let wasClosed = openRowId != rowId
contentOffset = -deleteWidth
openRowId = rowId
if wasClosed {
triggerSelectionHaptic()
}
} else {
contentOffset = 0
if openRowId == rowId {
openRowId = nil
}
}
}
)
.animation(.easeOut(duration: 0.18), value: contentOffset)
}
.clipped()
.animation(.easeOut(duration: 0.18), value: showsDeleteAction)
.onChange(of: openRowId) { _, newValue in
if newValue != rowId {
contentOffset = 0
}
}
.onChange(of: isDeleting) { _, newValue in
if newValue {
contentOffset = 0
}
}
}
}
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) {
icon
VStack(alignment: .leading, spacing: 8) {
HStack(spacing: 10) {
Text(item.title)
.font(AppTypography.heading2)
.foregroundStyle(AppColors.textPrimary)
.lineLimit(1)
.minimumScaleFactor(0.9)
if item.isPrimary {
Text("PRINCIPAL")
.font(AppTypography.overline)
.foregroundStyle(AppColors.textPrimary)
.lineLimit(1)
.padding(EdgeInsets(top: 5, leading: 9, bottom: 5, trailing: 9))
.background(AppColors.tertiary)
.clipShape(Capsule())
}
}
Text(item.detail)
.font(AppTypography.body)
.foregroundStyle(AppColors.textMuted)
.lineLimit(2)
}
Spacer(minLength: 8)
if hasActions {
Rectangle()
.fill(AppColors.backgroundLight)
.frame(width: 1, height: 96)
}
VStack(spacing: 20) {
if let onEdit {
Button(action: onEdit) {
Image(systemName: "pencil")
.font(.system(size: 20))
.foregroundStyle(AppColors.textMuted)
}
}
if let onSetDefault, item.isPrimary == false {
Button(action: onSetDefault) {
Image(systemName: "star")
.font(.system(size: 20))
.foregroundStyle(AppColors.textMuted)
}
}
}
.frame(width: hasActions ? 40 : 0)
}
.padding(.horizontal, 16)
.padding(.vertical, 20)
.background(AppColors.surface)
.clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusXL, style: .continuous))
}
var icon: some View {
Image(systemName: item.icon)
.font(.system(size: 28))
.foregroundStyle(item.isPrimary ? AppColors.primary : AppColors.textPrimary)
.frame(width: 84, height: 84)
.background(item.isPrimary ? AppColors.brandSoft : AppColors.backgroundLight)
.clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous))
}
}
struct AddressListItem: Identifiable {
let id = UUID()
let title: String
let detail: String
let icon: String
let isPrimary: Bool
}

View File

@@ -0,0 +1,448 @@
import SwiftUI
struct AddressesView: View {
let message: String?
@Binding var appState: AppState
var selectionMode: Bool = false
@Environment(\.dismiss) var dismiss
@State var isLoading = false
@State var errorMessage: String? = nil
@State var addresses: [CustomerAddress] = []
@State var openAddAddressForm = false
@State var editingAddress: CustomerAddress? = nil
@State var openSwipeRowId: String? = nil
@State var deletingRowId: String? = nil
@State var settingDefaultRowId: String? = nil
let tabBarClearance: CGFloat = 96
var body: some View {
ZStack {
AppColors.backgroundLight
.ignoresSafeArea()
ScrollView(showsIndicators: false) {
VStack(spacing: 20) {
screenHeader(title: "Meus Endereços", onBack: { dismiss() })
if let message {
Text(message)
.font(AppTypography.body)
.foregroundStyle(AppColors.textPrimary)
.multilineTextAlignment(.center)
.padding(.horizontal, 20)
.padding(.vertical, 14)
.frame(maxWidth: .infinity, alignment: .center)
.background(AppColors.brandSoft)
.clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous))
}
VStack(spacing: 16) {
if isLoading {
ProgressView()
.padding(.top, 24)
} else if let errorMessage {
Text(errorMessage)
.font(AppTypography.body)
.foregroundStyle(AppColors.textMuted)
.multilineTextAlignment(.center)
.padding(.top, 24)
} else if addresses.isEmpty {
Text("Nenhum endereço cadastrado")
.font(AppTypography.body)
.foregroundStyle(AppColors.textMuted)
.padding(.top, 24)
} else {
ForEach(Array(addresses.enumerated()), id: \.offset) { index, address in
let rowId = addressRowId(for: address, index: index)
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
}
return index == 0
}()
if selectionMode {
Button {
selectAddress(address)
} label: {
AddressCard(item: addressToListItem(address, isPrimary: isPrimary))
}
.buttonStyle(.plain)
} else if addresses.count > 1 {
SwipeToDeleteAddressRow(
rowId: rowId,
openRowId: $openSwipeRowId,
isDeleting: deletingRowId == rowId,
onDelete: { deleteAddress(address, rowId: rowId) }
) {
AddressCard(
item: addressToListItem(address, isPrimary: isPrimary),
onEdit: { beginEditing(address) },
onSetDefault: { setDefaultAddress(address, rowId: rowId) }
)
.appContentShape(Rectangle())
.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 || 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)
}
}
}
}
}
}
.padding(.horizontal, 20)
.padding(.top, 18)
}
VStack {
Spacer()
bottomOverlay
.padding(.bottom, tabBarClearance)
}
}
.navigationBarBackButtonHidden(true)
.appHiddenNavigationBar()
.sheet(isPresented: $openAddAddressForm) {
NavigationStack {
AddAddressFormView(existingAddress: editingAddress) { updatedAddresses, wasEditing in
addresses = updatedAddresses
applyPreferredAddress(from: updatedAddresses)
let title = wasEditing ? "Endereço atualizado com sucesso." : "Endereço adicionado com sucesso."
SnackbarCenter.shared.show(title: title, style: .success, icon: "checkmark.seal.fill", duration: 3.0)
}
}
}
.onChange(of: openAddAddressForm) { _, isOpen in
if isOpen == false {
editingAddress = nil
}
}
.onAppear {
if isLoading == false, addresses.isEmpty {
Task {
await loadAddresses()
}
}
}
}
var bottomOverlay: some View {
ZStack(alignment: .bottom) {
Rectangle()
.fill(AppColors.backgroundLight)
.frame(height: 136)
Button(action: {
editingAddress = nil
openAddAddressForm = true
}) {
HStack(spacing: 12) {
Image(systemName: "mappin.circle.fill")
.font(.system(size: 24))
Text("Adicionar novo endereço")
.font(AppTypography.heading3)
}
.foregroundStyle(AppColors.textInverse)
.frame(maxWidth: .infinity)
.padding(.vertical, 18)
.background(AppColors.primary)
.clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusXL, style: .continuous))
}
.buttonStyle(.plain)
.padding(.horizontal, 20)
.padding(.bottom, 14)
}
}
private func screenHeader(title: String, onBack: @escaping () -> Void) -> some View {
ZStack {
Text(title)
.font(AppTypography.heading2)
.foregroundStyle(AppColors.textPrimary)
HStack {
Button(action: onBack) {
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 selectAddress(_ address: CustomerAddress) {
appState.address.selectedId = address.id
let label = (address.label ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
appState.address.display = label.isEmpty ? "Defina seu endereco" : label
if let lat = address.latLong?.first, let lng = address.latLong?.dropFirst().first {
appState.address.latitude = lat
appState.address.longitude = lng
} else {
appState.address.latitude = nil
appState.address.longitude = nil
}
SessionStateStore.saveAddress(appState.address)
if selectionMode {
dismiss()
}
}
private func beginEditing(_ address: CustomerAddress) {
openSwipeRowId = nil
editingAddress = address
openAddAddressForm = true
}
private func applyPreferredAddress(from updatedAddresses: [CustomerAddress]) {
let selected = resolvePreferredAddress(from: updatedAddresses)
appState.address.selectedId = selected?.id
appState.address.display = selected?.label?.isEmpty == false ? (selected?.label ?? "Defina seu endereco") : "Defina seu endereco"
if let lat = selected?.latLong?.first, let lng = selected?.latLong?.dropFirst().first {
appState.address.latitude = lat
appState.address.longitude = lng
} else {
appState.address.latitude = nil
appState.address.longitude = nil
}
SessionStateStore.saveAddress(appState.address)
}
private func addressRowId(for address: CustomerAddress, index: Int) -> String {
if let id = address.id, id.isEmpty == false {
return "addr:\(id)"
}
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 addresses.count > 1, deletingRowId == nil else { return }
deletingRowId = rowId
openSwipeRowId = nil
Task {
do {
let response = try await ApiService().deleteCustomerAddress(address)
await MainActor.run {
deletingRowId = nil
guard response.error == false else {
let message = response.message ?? "Não foi possível excluir o endereço."
SnackbarCenter.shared.show(title: message, style: .error, icon: "xmark.octagon.fill", duration: 4.0)
return
}
let updatedAddresses = response.result?.addressBook ?? []
addresses = updatedAddresses
applyPreferredAddress(from: updatedAddresses)
SnackbarCenter.shared.show(title: "Endereço removido com sucesso.", style: .success, icon: "checkmark.seal.fill", duration: 3.0)
}
} catch {
await MainActor.run {
deletingRowId = nil
let message = error.localizedDescription
SnackbarCenter.shared.show(title: message, style: .error, icon: "xmark.octagon.fill", duration: 4.0)
}
}
}
}
func addressToListItem(_ address: CustomerAddress, isPrimary: Bool) -> AddressListItem {
let title = address.label?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false ? (address.label ?? "Endereço") : "Endereço"
let line1 = [address.address, address.number]
.compactMap { $0?.trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { $0.isEmpty == false }
.joined(separator: ", ")
let line2 = [address.neighborhood, address.city, address.state]
.compactMap { $0?.trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { $0.isEmpty == false }
.joined(separator: ", ")
let detail = [line1, line2]
.filter { $0.isEmpty == false }
.joined(separator: " - ")
return AddressListItem(
title: title,
detail: detail.isEmpty ? "Endereço sem detalhes" : detail,
icon: iconName(for: title),
isPrimary: isPrimary
)
}
func iconName(for label: String) -> String {
let normalized = label.folding(options: [.diacriticInsensitive, .caseInsensitive], locale: .current)
if normalized.contains("casa") {
return "house.fill"
}
if normalized.contains("trabalho") {
return "briefcase.fill"
}
return "mappin.and.ellipse"
}
@MainActor
func loadAddresses() async {
isLoading = true
errorMessage = nil
do {
let service = ApiService()
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
return
}
if let customer = response.result {
appState.profile.id = customer.id
appState.profile.name = customer.name
appState.profile.email = customer.email
appState.profile.phone = customer.phoneNumber ?? appState.profile.phone
appState.profile.profilePicture = customer.profilePicture ?? ""
appState.profile.notificationsEnabled = customer.notificationsEnabled ?? false
appState.profile.faceIdEnabled = customer.faceIdEnabled ?? false
SessionStateStore.setActiveUserKey(
SessionStateStore.makeUserKey(profileId: customer.id, email: customer.email)
)
addresses = customer.addressBook ?? []
} else {
addresses = []
}
if let selected = resolvePreferredAddress(from: addresses) {
appState.address.selectedId = selected.id
appState.address.display = selected.label ?? "Defina seu endereco"
if let lat = selected.latLong?.first, let lng = selected.latLong?.dropFirst().first {
appState.address.latitude = lat
appState.address.longitude = lng
}
SessionStateStore.saveAddress(appState.address)
}
} catch {
errorMessage = error.localizedDescription
}
isLoading = false
}
private func resolvePreferredAddress(from list: [CustomerAddress]) -> CustomerAddress? {
guard list.isEmpty == false else { return nil }
let selectedId = appState.address.selectedId?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if selectedId.isEmpty == false,
let byId = list.first(where: { ($0.id ?? "").trimmingCharacters(in: .whitespacesAndNewlines) == selectedId }) {
return byId
}
let normalizedDisplay = appState.address.display
.trimmingCharacters(in: .whitespacesAndNewlines)
.folding(options: [.diacriticInsensitive, .caseInsensitive], locale: .current)
.lowercased()
if normalizedDisplay.isEmpty == false && normalizedDisplay != "defina seu endereco",
let byLabel = list.first(where: {
(($0.label ?? "")
.trimmingCharacters(in: .whitespacesAndNewlines)
.folding(options: [.diacriticInsensitive, .caseInsensitive], locale: .current)
.lowercased()) == normalizedDisplay
}) {
return byLabel
}
if let lat = appState.address.latitude, let lng = appState.address.longitude,
let byCoordinate = list.first(where: { address in
guard let addrLat = address.latLong?.first,
let addrLng = address.latLong?.dropFirst().first else { return false }
return abs(addrLat - lat) < 0.00001 && abs(addrLng - lng) < 0.00001
}) {
return byCoordinate
}
return list.first
}
}

View File

@@ -0,0 +1,407 @@
import Foundation
import SwiftUI
struct CartView: View {
@Binding var appState: AppState
@Binding var selectedTab: MainTab
let enterAuth: () -> Void
@State var openCheckout = false
@State var couponCode = ""
@State var appliedCouponCode: String? = nil
@State var deliveryFee: Double? = nil
@State var selectedCustomerAddress: CustomerAddress? = nil
@State var isLoadingDeliveryFee = false
var body: some View {
ScrollView(showsIndicators: false) {
VStack(spacing: 16) {
Text("Meu Carrinho")
.font(AppTypography.heading1)
.foregroundStyle(AppColors.textPrimary)
.frame(maxWidth: .infinity, alignment: .center)
.padding(.top, 20)
if appState.cart.items.isEmpty {
VStack(spacing: 10) {
Text("Seu carrinho está vazio")
.font(AppTypography.heading3)
.foregroundStyle(AppColors.textPrimary)
Text("Adicione produtos para continuar.")
.font(AppTypography.body)
.foregroundStyle(AppColors.textMuted)
}
.frame(maxWidth: .infinity, minHeight: 280, alignment: .center)
} else {
VStack(spacing: 12) {
ForEach(appState.cart.items) { item in
cartItemRow(item)
}
}
.padding(.horizontal, 20)
// couponSection
// .padding(.horizontal, 20)
summarySection
.padding(.horizontal, 20)
}
}
.padding(.bottom, 120)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(AppColors.backgroundLight)
.navigationDestination(isPresented: $openCheckout) {
CheckoutView(appState: $appState, selectedTab: $selectedTab)
}
.task(id: deliveryFeeWatchKey) {
await refreshDeliveryFee()
}
}
private var subtotalValue: Double {
appState.cart.items.reduce(0) { $0 + (Double($1.quantity) * $1.unitPrice) }
}
private var totalValue: Double {
max(0, subtotalValue + (deliveryFee ?? 0) - effectiveDiscountValue)
}
private var effectiveDiscountValue: Double {
let normalizedCoupon = (appliedCouponCode ?? "")
.trimmingCharacters(in: .whitespacesAndNewlines)
.uppercased()
if normalizedCoupon == "DESCONTO10" {
return min(subtotalValue, subtotalValue * 0.1)
}
return 0
}
private var discountLabelValue: String {
if effectiveDiscountValue <= 0.0001 {
return formatCurrency(0)
}
return "-\(formatCurrency(effectiveDiscountValue))"
}
private var deliveryFeeWatchKey: String {
let storeId = appState.cart.storeId ?? "nil"
let selectedId = appState.address.selectedId ?? "nil"
let display = appState.address.display.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
let lat = appState.address.latitude.map { String(format: "%.5f", $0) } ?? "nil"
let lng = appState.address.longitude.map { String(format: "%.5f", $0) } ?? "nil"
return "\(storeId)|\(selectedId)|\(display)|\(lat)|\(lng)"
}
private var couponSection: some View {
VStack(alignment: .leading, spacing: 10) {
Text("Cupom de Desconto")
.font(AppTypography.heading2)
.foregroundStyle(AppColors.textPrimary)
HStack(spacing: 10) {
HStack(spacing: 8) {
Image(systemName: "ticket")
.foregroundStyle(AppColors.textMuted)
TextField("Inserir cupom", text: $couponCode)
.appNoAutoCap()
}
.padding(.horizontal, 12)
.frame(height: 50)
.background(AppColors.surface)
.overlay(
RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous)
.stroke(AppColors.brandSoft, lineWidth: 1)
)
.clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous))
Button("Aplicar") {
applyCoupon()
}
.font(AppTypography.heading3)
.foregroundStyle(AppColors.textInverse)
.frame(width: 120, height: 50)
.background(AppColors.brandDark)
.clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous))
.buttonStyle(.plain)
}
if let appliedCouponCode {
Text("Cupom aplicado: \(appliedCouponCode)")
.font(.caption)
.foregroundStyle(AppColors.primary)
}
}
}
private var summarySection: some View {
VStack(alignment: .leading, spacing: 12) {
Text("Resumo de Valores")
.font(AppTypography.heading2)
.foregroundStyle(AppColors.textPrimary)
summaryRow(title: "Subtotal", value: formatCurrency(subtotalValue))
summaryRow(title: "Taxa de Entrega", value: deliveryFeeLabel)
summaryRow(title: "Desconto", value: discountLabelValue, valueColor: effectiveDiscountValue > 0 ? Color.red : AppColors.textMuted)
Divider()
summaryRow(title: "Total", value: formatCurrency(totalValue), highlighted: true)
Button {
guard appState.session.isAuthenticated else {
enterAuth()
return
}
openCheckout = true
} label: {
HStack(spacing: 10) {
Text("Ir para o Pagamento")
.font(AppTypography.heading2)
Image(systemName: "arrow.right")
.font(.system(size: 18, weight: .bold))
}
.foregroundStyle(AppColors.textInverse)
.frame(maxWidth: .infinity, minHeight: 54)
.background(AppColors.primary)
.clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous))
}
.buttonStyle(.plain)
}
.padding(16)
.background(AppColors.surface)
.clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusXL, style: .continuous))
}
private var deliveryFeeLabel: String {
if isLoadingDeliveryFee {
return "Calculando..."
}
if let deliveryFee {
return formatCurrency(deliveryFee)
}
return "Indisponível"
}
private func summaryRow(title: String, value: String, valueColor: Color? = nil, highlighted: Bool = false) -> some View {
HStack {
Text(title)
.font(highlighted ? AppTypography.heading2 : AppTypography.body)
.foregroundStyle(AppColors.textMuted)
Spacer()
Text(value)
.font(highlighted ? AppTypography.heading1 : AppTypography.heading3)
.foregroundStyle(valueColor ?? AppColors.textPrimary)
}
}
private func cartItemRow(_ item: CartItemState) -> some View {
HStack(spacing: 14) {
AsyncStoreImage(imageURL: item.imageURL)
.frame(width: 78, height: 78)
.clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous))
VStack(alignment: .leading, spacing: 6) {
Text(item.name)
.font(AppTypography.heading3)
.foregroundStyle(AppColors.textPrimary)
.lineLimit(1)
if let details = item.details, details.isEmpty == false {
Text(details)
.font(.caption)
.foregroundStyle(AppColors.textMuted)
.lineLimit(2)
}
Text(formatCurrency(item.unitPrice))
.font(AppTypography.body)
.foregroundStyle(AppColors.textMuted)
}
Spacer()
HStack(spacing: 10) {
Button(action: { appState.cart.decrement(itemId: item.id) }) {
Image(systemName: "minus")
.font(.system(size: 13, weight: .bold))
.foregroundStyle(AppColors.textPrimary)
.frame(width: 32, height: 32)
.background(AppColors.backgroundLight)
.clipShape(Circle())
}
.buttonStyle(.plain)
Text("\(item.quantity)")
.font(AppTypography.heading3)
.foregroundStyle(AppColors.textPrimary)
.frame(minWidth: 18)
Button(action: { appState.cart.increment(itemId: item.id) }) {
Image(systemName: "plus")
.font(.system(size: 13, weight: .bold))
.foregroundStyle(AppColors.textPrimary)
.frame(width: 32, height: 32)
.background(AppColors.tertiary)
.clipShape(Circle())
}
.buttonStyle(.plain)
}
.padding(.horizontal, 8)
.padding(.vertical, 6)
.background(AppColors.backgroundLight)
.clipShape(Capsule())
}
.padding(.horizontal, 12)
.padding(.vertical, 12)
.background(AppColors.surface)
.clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous))
}
private func applyCoupon() {
let normalized = couponCode.trimmingCharacters(in: .whitespacesAndNewlines).uppercased()
guard normalized.isEmpty == false else {
appliedCouponCode = nil
return
}
if normalized == "DESCONTO10" {
appliedCouponCode = normalized
return
}
appliedCouponCode = nil
}
private func formatCurrency(_ value: Double) -> String {
String(format: "R$ %.2f", value).replacingOccurrences(of: ".", with: ",")
}
@MainActor
private func refreshDeliveryFee() async {
guard appState.cart.items.isEmpty == false else {
deliveryFee = nil
selectedCustomerAddress = nil
return
}
guard let storeId = appState.cart.storeId, storeId.isEmpty == false else {
deliveryFee = nil
selectedCustomerAddress = nil
return
}
isLoadingDeliveryFee = true
defer { isLoadingDeliveryFee = false }
do {
let profileResponse = try await ApiService().profile(forceRefresh: true)
let addresses = profileResponse.result?.addressBook ?? []
if let selectedId = appState.address.selectedId, selectedId.isEmpty == false {
selectedCustomerAddress = addresses.first(where: { $0.id == selectedId })
} else {
selectedCustomerAddress = nil
}
// id can be nil for some address book entries lat/lng is set
// immediately and reliably at selection time (AddressesView.
// selectAddress), so it's a stronger signal than the label match
// below, which silently collides whenever two addresses share an
// empty/duplicate label. Without this, an id-less address falls
// through to addresses.first and never actually "changes".
if selectedCustomerAddress == nil,
let lat = appState.address.latitude, let lng = appState.address.longitude {
selectedCustomerAddress = addresses.first { address in
guard let addrLat = address.latLong?.first,
let addrLng = address.latLong?.dropFirst().first else { return false }
return abs(addrLat - lat) < 0.00001 && abs(addrLng - lng) < 0.00001
}
}
if selectedCustomerAddress == nil {
let display = appState.address.display
.trimmingCharacters(in: .whitespacesAndNewlines)
.lowercased()
if display.isEmpty == false, display != "defina seu endereco" {
selectedCustomerAddress = addresses.first {
($0.label ?? "").trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == display
}
}
}
if selectedCustomerAddress == nil {
selectedCustomerAddress = addresses.first
}
if let selected = selectedCustomerAddress {
// appState.address reflects the address the user just picked
// authoritative. Only fill in gaps from the address book here,
// never overwrite a live selection with a (possibly stale)
// cached record, or the fee/validation payload below can end
// up built against the wrong coordinates.
if appState.address.selectedId == nil || appState.address.selectedId?.isEmpty == true {
appState.address.selectedId = selected.id
}
if appState.address.display.isEmpty || appState.address.display == "Defina seu endereco" {
let label = (selected.label ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
if label.isEmpty == false {
appState.address.display = label
}
}
if appState.address.latitude == nil || appState.address.longitude == nil,
let lat = selected.latLong?.first, let lng = selected.latLong?.dropFirst().first {
appState.address.latitude = lat
appState.address.longitude = lng
}
SessionStateStore.saveAddress(appState.address)
}
var payloadLat = appState.address.latitude ?? selectedCustomerAddress?.latLong?.first
var payloadLng = appState.address.longitude ?? selectedCustomerAddress?.latLong?.dropFirst().first
// Some saved addresses have no lat/long (CEP lookup at creation
// time didn't return coordinates). Without coordinates the
// backend can't tell this address apart from any other, so the
// fee silently never changes. Geocode locally as a fallback.
if payloadLat == nil || payloadLng == nil {
if let coordinate = await LocationService.geocodeAddress(
street: selectedCustomerAddress?.address,
number: selectedCustomerAddress?.number,
neighborhood: selectedCustomerAddress?.neighborhood,
city: selectedCustomerAddress?.city,
state: selectedCustomerAddress?.state,
zip: selectedCustomerAddress?.zipCode
) {
payloadLat = coordinate.0
payloadLng = coordinate.1
appState.address.latitude = coordinate.0
appState.address.longitude = coordinate.1
SessionStateStore.saveAddress(appState.address)
}
}
let payload = ValidateDeliveryAddressPayload(
address: ValidateDeliveryAddressDataPayload(
street: selectedCustomerAddress?.address,
number: selectedCustomerAddress?.number,
neighborhood: selectedCustomerAddress?.neighborhood,
city: selectedCustomerAddress?.city,
state: selectedCustomerAddress?.state,
zip: selectedCustomerAddress?.zipCode,
lat: payloadLat,
lng: payloadLng
)
)
let validationResponse = try await ApiService().validateDeliveryAddress(storeId: storeId, payload: payload)
guard validationResponse.error == false,
validationResponse.result?.deliveryAllowed == true,
let fee = validationResponse.result?.deliveryFee else {
deliveryFee = nil
return
}
deliveryFee = fee
} catch {
deliveryFee = nil
}
}
}

View File

@@ -0,0 +1,41 @@
import Foundation
enum CheckoutDeliveryType: String {
case delivery = "DELIVERY"
case pickup = "PICKUP"
}
enum CheckoutPaymentMethod: String {
case pix = "PIX"
case creditCard = "CREDIT_CARD"
case debitCard = "DEBIT_CARD"
case money = "MONEY"
case voucher = "VOUCHER"
var label: String {
switch self {
case .pix: return "PIX"
case .creditCard: return "Cartão de Crédito"
case .debitCard: return "Cartão de Débito"
case .money: return "Dinheiro"
case .voucher: return "Vale Refeição/Alimentação"
}
}
var subtitle: String? {
switch self {
case .pix: return "Aprovação imediata"
case .creditCard: return "No app: rápido e seguro"
default: return nil
}
}
var iconName: String {
switch self {
case .pix: return "bolt.fill"
case .creditCard, .debitCard: return "creditcard.fill"
case .money: return "banknote.fill"
case .voucher: return "ticket.fill"
}
}
}

View File

@@ -0,0 +1,471 @@
import SwiftUI
extension CheckoutView {
enum CheckoutPayloadValidationError: LocalizedError {
case emptyCart
case missingCustomerName
case missingCustomerEmail
case missingCustomerPhone
case missingAddressStreet
case missingAddressNumber
case missingAddressNeighborhood
var errorDescription: String? {
switch self {
case .emptyCart: return "Carrinho vazio."
case .missingCustomerName: return "Nome do cliente não informado."
case .missingCustomerEmail: return "Email do cliente não informado."
case .missingCustomerPhone: return "Telefone do cliente não informado."
case .missingAddressStreet: return "Rua do endereço não informada."
case .missingAddressNumber: return "Número do endereço não informado."
case .missingAddressNeighborhood: return "Bairro do endereço não informado."
}
}
}
var checkoutAddressWatchKey: String {
let selectedId = appState.address.selectedId ?? "nil"
let display = appState.address.display.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
let lat = appState.address.latitude.map { String(format: "%.5f", $0) } ?? "nil"
let lng = appState.address.longitude.map { String(format: "%.5f", $0) } ?? "nil"
return "\(selectedId)|\(display)|\(lat)|\(lng)"
}
func formatCurrency(_ value: Double) -> String {
String(format: "R$ %.2f", value).replacingOccurrences(of: ".", with: ",")
}
@MainActor
func updateMinOrderSnackbar() {
guard isBelowMinOrder else {
SnackbarCenter.shared.dismissPersistent()
return
}
let missing = formatCurrency(minOrderValue - totalValue)
SnackbarCenter.shared.show(
title: "Pedido mínimo de \(formatCurrency(minOrderValue)). Faltam \(missing) para finalizar.",
style: .warning,
icon: "exclamationmark.circle.fill",
isPersistent: true
)
}
@MainActor
func loadStoreInfoIfNeeded() async {
guard let storeId = appState.cart.storeId, storeId.isEmpty == false else { return }
do {
let response = try await ApiService().storeInfo(storeId: storeId)
if response.error {
errorMessage = response.message ?? "Não foi possível carregar opções de checkout."
return
}
storeInfo = response.result
if showPaymentModeToggle == false {
useInAppPayment = true
}
errorMessage = nil
} catch {
errorMessage = "Não foi possível carregar opções de checkout."
}
}
@MainActor
func refreshSelectedCustomerAddress() async {
do {
let response = try await ApiService().profile(forceRefresh: true)
if let customer = response.result {
appState.profile.id = customer.id
appState.profile.name = customer.name
appState.profile.email = customer.email
if let phoneNumber = customer.phoneNumber, phoneNumber.isEmpty == false {
appState.profile.phone = phoneNumber
}
appState.profile.profilePicture = customer.profilePicture ?? ""
appState.profile.notificationsEnabled = customer.notificationsEnabled ?? false
appState.profile.faceIdEnabled = customer.faceIdEnabled ?? false
SessionStateStore.setActiveUserKey(
SessionStateStore.makeUserKey(profileId: customer.id, email: customer.email)
)
}
let addresses = response.result?.addressBook ?? []
if let selectedId = appState.address.selectedId, selectedId.isEmpty == false {
selectedCustomerAddress = addresses.first(where: { $0.id == selectedId })
} else {
selectedCustomerAddress = nil
}
// id can be nil for some address book entries lat/lng is set
// immediately and reliably at selection time (AddressesView.
// selectAddress), so it's a stronger signal than the label match
// below, which silently collides whenever two addresses share an
// empty/duplicate label. Without this ordered first, an id-less
// address falls through to addresses.first and never actually
// "changes" even though delivery to it is allowed.
if selectedCustomerAddress == nil,
let lat = appState.address.latitude, let lng = appState.address.longitude {
selectedCustomerAddress = addresses.first { address in
guard let addrLat = address.latLong?.first,
let addrLng = address.latLong?.dropFirst().first else { return false }
return abs(addrLat - lat) < 0.00001 && abs(addrLng - lng) < 0.00001
}
}
if selectedCustomerAddress == nil {
let display = appState.address.display
.trimmingCharacters(in: .whitespacesAndNewlines)
.lowercased()
if display.isEmpty == false, display != "defina seu endereco" {
selectedCustomerAddress = addresses.first {
($0.label ?? "").trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == display
}
}
}
if selectedCustomerAddress == nil {
selectedCustomerAddress = addresses.first
}
if let selected = selectedCustomerAddress {
// appState.address reflects the address the user just picked
// authoritative. Only fill in gaps from the address book here,
// never overwrite a live selection with a (possibly stale)
// cached record, or delivery validation below runs against
// the wrong coordinates and can wrongly report the address
// as not served, reverting the user's pick.
if appState.address.selectedId == nil || appState.address.selectedId?.isEmpty == true {
appState.address.selectedId = selected.id
}
if appState.address.display.isEmpty || appState.address.display == "Defina seu endereco" {
let label = (selected.label ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
if label.isEmpty == false {
appState.address.display = label
}
}
if appState.address.latitude == nil || appState.address.longitude == nil,
let lat = selected.latLong?.first, let lng = selected.latLong?.dropFirst().first {
appState.address.latitude = lat
appState.address.longitude = lng
}
SessionStateStore.saveAddress(appState.address)
}
} catch {
selectedCustomerAddress = nil
}
}
@MainActor
func validateDeliveryAddressIfNeeded() async {
guard isDeliveryMode else {
addressValidationBlocked = false
addressValidationMessage = nil
baseDeliveryFee = nil
return
}
guard let storeId = appState.cart.storeId, storeId.isEmpty == false else { return }
baseDeliveryFee = nil
// Some saved addresses have no lat/long (CEP lookup at creation time
// didn't return coordinates). Without coordinates the backend can't
// tell this address apart from any other, so the fee silently never
// changes. Geocode locally as a fallback before validating.
if appState.address.latitude == nil || appState.address.longitude == nil {
if let coordinate = await LocationService.geocodeAddress(
street: selectedCustomerAddress?.address,
number: selectedCustomerAddress?.number,
neighborhood: selectedCustomerAddress?.neighborhood,
city: selectedCustomerAddress?.city,
state: selectedCustomerAddress?.state,
zip: selectedCustomerAddress?.zipCode
) {
appState.address.latitude = coordinate.0
appState.address.longitude = coordinate.1
SessionStateStore.saveAddress(appState.address)
}
}
let payload = ValidateDeliveryAddressPayload(
address: ValidateDeliveryAddressDataPayload(
street: selectedCustomerAddress?.address,
number: selectedCustomerAddress?.number,
neighborhood: selectedCustomerAddress?.neighborhood,
city: selectedCustomerAddress?.city,
state: selectedCustomerAddress?.state,
zip: selectedCustomerAddress?.zipCode,
lat: appState.address.latitude,
lng: appState.address.longitude
)
)
isValidatingAddress = true
defer { isValidatingAddress = false }
do {
let response = try await ApiService().validateDeliveryAddress(storeId: storeId, payload: payload)
if response.error {
addressValidationBlocked = true
addressValidationMessage = response.message ?? "Não foi possível validar o endereço de entrega."
baseDeliveryFee = nil
return
}
let result = response.result
let allowed = result?.deliveryAllowed ?? false
addressValidationBlocked = allowed == false
addressValidationMessage = result?.reasonMessage
if allowed {
lastAcceptedAddressState = appState.address
} else {
showAddressNotServedAlert = true
baseDeliveryFee = nil
}
if allowed {
if let fee = result?.deliveryFee {
baseDeliveryFee = fee
} else {
addressValidationBlocked = true
addressValidationMessage = "Não foi possível calcular a taxa de entrega para este endereço."
baseDeliveryFee = nil
}
}
} catch {
addressValidationBlocked = true
addressValidationMessage = "Não foi possível validar o endereço de entrega."
baseDeliveryFee = nil
}
}
func normalizeSelectedOptions() {
if availableDeliveryTypes.contains(deliveryType) == false,
let first = availableDeliveryTypes.first {
deliveryType = first
}
if useInAppPayment {
if availableInAppPaymentMethods.contains(paymentMethod) == false,
let first = availableInAppPaymentMethods.first {
paymentMethod = first
}
} else {
if availableStoreMachineMethods.contains(paymentMethod) == false,
let first = availableStoreMachineMethods.first {
paymentMethod = first
}
}
if lastAcceptedAddressState == nil {
lastAcceptedAddressState = appState.address
}
}
func restoreLastAcceptedAddress() {
guard let snapshot = lastAcceptedAddressState else { return }
isRestoringAddress = true
appState.address = snapshot
SessionStateStore.saveAddress(snapshot)
Task { @MainActor in
await refreshSelectedCustomerAddress()
addressValidationBlocked = false
addressValidationMessage = nil
isRestoringAddress = false
}
}
@MainActor
func handleConfirmPaymentTap() async {
guard canConfirmPayment else { return }
guard let storeId = appState.cart.storeId, storeId.isEmpty == false else {
SnackbarCenter.shared.show(title: "Loja do pedido não encontrada.", style: .error, icon: "xmark.octagon.fill", duration: 2.0)
return
}
await refreshSelectedCustomerAddress()
let effectivePaymentMethod = paymentMethod
if useInAppPayment && availableInAppPaymentMethods.contains(effectivePaymentMethod) == false {
SnackbarCenter.shared.show(title: "Método de pagamento não suportado no app.", style: .warning, icon: "exclamationmark.triangle.fill", duration: 2.0)
return
}
// Crédito pelo app abre seleção de cartão antes de criar pedido
if useInAppPayment && effectivePaymentMethod == .creditCard {
showCardSelectionSheet = true
return
}
let payloadBuildResult = buildCreateOrderPayload(paymentMethod: effectivePaymentMethod, savedCardId: nil)
guard case .success(let payload) = payloadBuildResult else {
let message: String
if case .failure(let reason) = payloadBuildResult {
message = reason.localizedDescription
} else {
message = "Dados do pedido incompletos."
}
SnackbarCenter.shared.show(title: message, style: .error, icon: "xmark.octagon.fill", duration: 2.0)
return
}
isSubmittingOrder = true
defer { isSubmittingOrder = false }
do {
let response = try await ApiService().createOrder(storeId: storeId, payload: payload)
let isInAppMethod = availableInAppPaymentMethods.contains(effectivePaymentMethod)
if useInAppPayment == false || isInAppMethod == false {
if response.error == false, let result = response.result {
let orderId = result.id ?? UUID().uuidString
appState.cart.clear()
SessionStateStore.clearPendingCartOrder()
orderTrackingContext = OrderTrackingContext(orderId: orderId, shortId: result.shortId)
} else {
SnackbarCenter.shared.show(title: response.message ?? "Não foi possível criar o pedido.", style: .error, icon: "xmark.octagon.fill", duration: 3.0)
}
return
}
handleOrderResponse(response, effectivePaymentMethod: effectivePaymentMethod)
} catch {
SnackbarCenter.shared.show(title: "Não foi possível criar o pedido.", style: .error, icon: "xmark.octagon.fill", duration: 3.0)
}
}
func buildCreateOrderPayload(
paymentMethod: CheckoutPaymentMethod,
savedCardId: String? = nil,
creditCard: CreditCardOrderPayload? = nil,
creditCardHolderInfo: SaveCardHolderInfoPayload? = nil,
clientCpfCnpj: String? = nil
) -> Result<CreateOrderPayload, CheckoutPayloadValidationError> {
guard appState.cart.items.isEmpty == false else { return .failure(.emptyCart) }
let profileName = appState.profile.name.trimmingCharacters(in: .whitespacesAndNewlines)
let profileEmail = appState.profile.email.trimmingCharacters(in: .whitespacesAndNewlines)
let profilePhone = appState.profile.phone.trimmingCharacters(in: .whitespacesAndNewlines)
guard profileName.isEmpty == false else { return .failure(.missingCustomerName) }
guard profileEmail.isEmpty == false else { return .failure(.missingCustomerEmail) }
guard profilePhone.isEmpty == false else { return .failure(.missingCustomerPhone) }
let addressPayload: CreateOrderAddressPayload?
if isDeliveryMode {
let street = selectedCustomerAddress?.address?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let number = selectedCustomerAddress?.number?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let neighborhood = selectedCustomerAddress?.neighborhood?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
guard street.isEmpty == false else { return .failure(.missingAddressStreet) }
guard number.isEmpty == false else { return .failure(.missingAddressNumber) }
guard neighborhood.isEmpty == false else { return .failure(.missingAddressNeighborhood) }
addressPayload = CreateOrderAddressPayload(
street: street,
number: number,
neighborhood: neighborhood,
city: selectedCustomerAddress?.city,
state: selectedCustomerAddress?.state,
zip: selectedCustomerAddress?.zipCode,
complement: selectedCustomerAddress?.complement
)
} else {
addressPayload = nil
}
return .success(
CreateOrderPayload(
customer: CreateOrderCustomerPayload(
name: profileName,
phone: profilePhone,
email: profileEmail,
asaasId: nil
),
items: appState.cart.toOrderItemsPayload(),
total: totalValue,
paymentMethod: paymentMethod.rawValue,
deliveryType: deliveryType.rawValue,
address: addressPayload,
savedCardId: savedCardId,
clientCpfCnpj: clientCpfCnpj,
creditCard: creditCard,
creditCardHolderInfo: creditCardHolderInfo
)
)
}
@MainActor
func confirmOrderWithSavedCard(cardId: String, storeId: String) async {
let payloadResult = buildCreateOrderPayload(paymentMethod: .creditCard, savedCardId: cardId)
guard case .success(let payload) = payloadResult else { return }
isSubmittingOrder = true
defer { isSubmittingOrder = false }
do {
let response = try await ApiService().createOrder(storeId: storeId, payload: payload)
handleOrderResponse(response, effectivePaymentMethod: .creditCard)
} catch {
SnackbarCenter.shared.show(title: "Não foi possível criar o pedido.", style: .error, icon: "xmark.octagon.fill", duration: 3.0)
}
}
func handleOrderResponse(_ response: ApiEnvelope<CreateOrderResult>, effectivePaymentMethod: CheckoutPaymentMethod) {
if response.error {
SnackbarCenter.shared.show(
title: response.message ?? "Não foi possível criar o pedido.",
style: .error,
icon: "xmark.octagon.fill",
duration: 3.0
)
return
}
guard let result = response.result else {
SnackbarCenter.shared.show(title: "Resposta de pagamento inválida.", style: .error, icon: "xmark.octagon.fill", duration: 3.0)
return
}
let orderSnapshot = result.asPublicOrderResult()
SessionStateStore.saveTrackedOrder(orderSnapshot)
let orderId = result.id ?? UUID().uuidString
if orderSnapshot.isPaymentConfirmed {
appState.cart.clear()
SessionStateStore.clearPendingCartOrder()
orderTrackingContext = OrderTrackingContext(orderId: orderId, shortId: result.shortId)
return
}
SessionStateStore.savePendingCartOrderId(orderId)
let pixFromPayment = result.payment?.pix
let pixFromPayload = result.paymentPayload
let copyPaste = (pixFromPayment?.copyPaste?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false)
? pixFromPayment?.copyPaste : pixFromPayload?.copyPaste
let qrCodeImage = (pixFromPayment?.qrCodeImage?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false)
? pixFromPayment?.qrCodeImage : pixFromPayload?.qrCodeImage
let expirationDate = (pixFromPayment?.expirationDate?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false)
? pixFromPayment?.expirationDate : pixFromPayload?.expirationDate
if let copyPaste, copyPaste.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false {
let itemsData = (try? JSONEncoder().encode(appState.cart.toOrderItemsPayload())).flatMap { String(data: $0, encoding: .utf8) } ?? "[]"
let storeId = appState.cart.storeId ?? ""
pixPaymentContext = PixPaymentContext(
id: orderId,
orderId: orderId,
shortId: result.shortId,
storeId: storeId,
copyPaste: copyPaste,
qrCodeImageBase64: qrCodeImage,
expirationDate: expirationDate,
total: totalValue,
profileName: appState.profile.name,
profileEmail: appState.profile.email,
profilePhone: appState.profile.phone,
addressZip: selectedCustomerAddress?.zipCode,
addressNumber: selectedCustomerAddress?.number,
deliveryType: deliveryType.rawValue,
itemsJSON: itemsData
)
return
}
orderTrackingContext = OrderTrackingContext(orderId: orderId, shortId: result.shortId)
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,235 @@
import SwiftUI
struct FiltersModalView: View {
@Binding var appState: AppState
@Environment(\.dismiss) var dismiss
@State var draftFilters: HomeFiltersState
init(appState: Binding<AppState>) {
_appState = appState
_draftFilters = State(initialValue: appState.wrappedValue.homeFilters)
}
var body: some View {
VStack(spacing: 0) {
header
ScrollView(showsIndicators: false) {
VStack(alignment: .leading, spacing: 28) {
sortSection
categoriesSection
priceSection
distanceSection
}
.padding(.horizontal, 24)
.padding(.top, 22)
.padding(.bottom, 120)
}
applyButton
.padding(.horizontal, 24)
.padding(.vertical, 18)
.background(AppColors.backgroundLight)
}
.background(AppColors.backgroundLight.ignoresSafeArea())
}
var header: some View {
HStack {
Button {
dismiss()
} label: {
Image(systemName: "xmark")
.font(.system(size: 20, weight: .medium))
.foregroundStyle(AppColors.textPrimary)
.frame(width: 36, height: 36)
}
.buttonStyle(.plain)
Spacer()
Text("Filtros")
.font(AppTypography.heading2)
.foregroundStyle(AppColors.textPrimary)
Spacer()
Button("Limpar") {
draftFilters.reset()
draftFilters.availableCategories = appState.homeFilters.availableCategories
}
.font(AppTypography.heading3)
.foregroundStyle(AppColors.secondary)
.buttonStyle(.plain)
}
.padding(.horizontal, 24)
.padding(.top, 14)
.padding(.bottom, 12)
.overlay(alignment: .bottom) {
Divider().overlay(Color.black.opacity(0.08))
}
}
var sortSection: some View {
VStack(alignment: .leading, spacing: 14) {
Text("Ordenar por")
.font(AppTypography.heading1)
.foregroundStyle(AppColors.textPrimary)
VStack(spacing: 10) {
ForEach(HomeSortOption.allCases) { option in
Button {
draftFilters.sortOption = option
} label: {
HStack(spacing: 14) {
Circle()
.fill(option == draftFilters.sortOption ? AppColors.tertiary : AppColors.surface)
.frame(width: 44, height: 44)
.overlay(
Image(systemName: option.icon)
.font(.system(size: 17, weight: .semibold))
.foregroundStyle(option == draftFilters.sortOption ? AppColors.textPrimary : AppColors.textMuted)
)
Text(option.title)
.font(AppTypography.heading2)
.foregroundStyle(AppColors.textPrimary)
Spacer()
Circle()
.stroke(option == draftFilters.sortOption ? Color.black : Color.black.opacity(0.2), lineWidth: 2)
.frame(width: 28, height: 28)
.overlay(
Circle()
.fill(option == draftFilters.sortOption ? Color.black : Color.clear)
.frame(width: 14, height: 14)
)
}
.padding(.horizontal, 16)
.frame(height: 88)
.background(Color.black.opacity(0.03))
.clipShape(RoundedRectangle(cornerRadius: 20, style: .continuous))
}
.buttonStyle(.plain)
}
}
}
}
var categoriesSection: some View {
VStack(alignment: .leading, spacing: 14) {
Divider().overlay(Color.black.opacity(0.08))
HStack(alignment: .center) {
Text("Categorias")
.font(AppTypography.heading1)
.foregroundStyle(AppColors.textPrimary)
Spacer()
Text("Ver todas")
.font(AppTypography.heading3)
.foregroundStyle(AppColors.textMuted)
}
LazyVGrid(columns: [GridItem(.adaptive(minimum: 120), spacing: 10)], alignment: .leading, spacing: 10) {
ForEach(draftFilters.availableCategories, id: \.self) { category in
let isSelected = draftFilters.selectedCategories.contains(category)
Button(category) {
if isSelected {
draftFilters.selectedCategories.remove(category)
} else {
draftFilters.selectedCategories.insert(category)
}
}
.font(AppTypography.heading2)
.foregroundStyle(AppColors.textPrimary)
.padding(.horizontal, 20)
.padding(.vertical, 12)
.background(isSelected ? AppColors.tertiary.opacity(0.7) : AppColors.surface)
.overlay(
Capsule().stroke(isSelected ? Color.clear : Color.black.opacity(0.12), lineWidth: 1)
)
.clipShape(Capsule())
.buttonStyle(.plain)
}
}
}
}
var priceSection: some View {
VStack(alignment: .leading, spacing: 14) {
Divider().overlay(Color.black.opacity(0.08))
Text("Preço")
.font(AppTypography.heading1)
.foregroundStyle(AppColors.textPrimary)
HStack(spacing: 12) {
ForEach(HomePriceTier.allCases) { tier in
let isSelected = draftFilters.selectedPriceTier == tier
Button(tier.rawValue) {
draftFilters.selectedPriceTier = isSelected ? nil : tier
}
.font(AppTypography.heading2)
.foregroundStyle(AppColors.textPrimary)
.frame(maxWidth: .infinity)
.frame(height: 68)
.background(isSelected ? AppColors.tertiary.opacity(0.7) : AppColors.surface)
.overlay(
RoundedRectangle(cornerRadius: 24, style: .continuous)
.stroke(isSelected ? Color.black : Color.black.opacity(0.12), lineWidth: isSelected ? 2 : 1)
)
.clipShape(RoundedRectangle(cornerRadius: 24, style: .continuous))
.buttonStyle(.plain)
}
}
}
}
var distanceSection: some View {
VStack(alignment: .leading, spacing: 14) {
Divider().overlay(Color.black.opacity(0.08))
HStack {
Text("Distância")
.font(AppTypography.heading1)
.foregroundStyle(AppColors.textPrimary)
Spacer()
Text("Até \(Int(draftFilters.maxDistanceKm))km")
.font(AppTypography.heading2)
.foregroundStyle(AppColors.textPrimary)
}
Slider(value: $draftFilters.maxDistanceKm, in: 1...10, step: 1)
.tint(AppColors.tertiary)
HStack {
Text("1km")
Spacer()
Text("10km")
}
.font(AppTypography.heading3)
.foregroundStyle(AppColors.textMuted)
}
}
var applyButton: some View {
Button {
appState.homeFilters.sortOption = draftFilters.sortOption
appState.homeFilters.selectedCategories = draftFilters.selectedCategories
appState.homeFilters.selectedPriceTier = draftFilters.selectedPriceTier
appState.homeFilters.maxDistanceKm = draftFilters.maxDistanceKm
appState.homeFilters.availableCategories = draftFilters.availableCategories
dismiss()
} label: {
Text("Aplicar Filtros")
.font(AppTypography.heading2)
.foregroundStyle(AppColors.textPrimary)
.frame(maxWidth: .infinity)
.frame(height: 62)
.background(AppColors.tertiary.opacity(0.7))
.clipShape(RoundedRectangle(cornerRadius: 20, style: .continuous))
}
.buttonStyle(.plain)
}
}

View File

@@ -0,0 +1,33 @@
import SwiftUI
enum HomeScrollCoordinateSpace {
static let name = "home-scroll"
}
struct HomeScrollOffsetPreferenceKey: PreferenceKey {
static let defaultValue: CGFloat = 0
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
value = nextValue()
}
}
struct ScrollOffsetObserver: View {
let onOffsetChange: (CGFloat) -> Void
var body: some View {
Color.clear
.frame(height: 0)
.background(
GeometryReader { geometry in
Color.clear.preference(
key: HomeScrollOffsetPreferenceKey.self,
value: geometry.frame(in: .named(HomeScrollCoordinateSpace.name)).minY
)
}
)
.onPreferenceChange(HomeScrollOffsetPreferenceKey.self) { minY in
onOffsetChange(-minY)
}
}
}

View File

@@ -0,0 +1,196 @@
import Foundation
import SwiftUI
extension HomeView {
func buildCategories(from stores: [StoreSummary]) -> [CategoryModel] {
var unique: [CategoryModel] = [
.init(id: "all", title: "Todas", systemIcon: "line.3.horizontal.decrease.circle", emojiIcon: nil)
]
var seen = Set<String>()
for store in stores {
let raw = (store.category ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
if raw.isEmpty { continue }
let dedupe = raw.lowercased()
if seen.contains(dedupe) { continue }
seen.insert(dedupe)
unique.append(.init(id: raw, title: raw, systemIcon: categoryIcon(for: raw), emojiIcon: nil))
}
return unique
}
@MainActor
func loadHomeCategories(withFallbackStores stores: [StoreSummary], forceRefresh: Bool = false) async {
let cacheKey = "public-categories"
if forceRefresh == false,
let cached: [CategoryModel] = AppContentCache.shared.value(for: cacheKey, as: [CategoryModel].self) {
categories = cached
return
}
do {
let response = try await ApiService().listPublicCategories(forceRefresh: forceRefresh)
if response.error == false, let remote = response.result, remote.isEmpty == false {
let mapped = mapPublicCategories(remote)
categories = mapped
AppContentCache.shared.set(mapped, for: cacheKey, ttl: AppCacheTTL.twoHours)
return
}
} catch {
// Fallback handled below.
}
let fallback = buildCategories(from: stores)
categories = fallback
AppContentCache.shared.set(fallback, for: cacheKey, ttl: AppCacheTTL.twoHours)
}
func mapPublicCategories(_ remote: [PublicCategory]) -> [CategoryModel] {
var mapped: [CategoryModel] = []
var seen = Set<String>()
for item in remote {
let id = item.id.trimmingCharacters(in: .whitespacesAndNewlines)
let title = item.name.trimmingCharacters(in: .whitespacesAndNewlines)
if id.isEmpty || title.isEmpty { continue }
if seen.contains(id.lowercased()) { continue }
seen.insert(id.lowercased())
mapped.append(
.init(
id: id,
title: title,
systemIcon: id.lowercased() == "all" ? "line.3.horizontal.decrease.circle" : nil,
emojiIcon: item.icon
)
)
}
if mapped.contains(where: { $0.id.lowercased() == "all" }) == false {
mapped.insert(.init(id: "all", title: "Todas", systemIcon: "line.3.horizontal.decrease.circle", emojiIcon: nil), at: 0)
} else {
mapped.sort { lhs, rhs in
if lhs.id.lowercased() == "all" { return true }
if rhs.id.lowercased() == "all" { return false }
return lhs.title < rhs.title
}
}
return mapped
}
func categoryIcon(for category: String) -> String {
let value = category.folding(options: [.diacriticInsensitive, .caseInsensitive], locale: .current).lowercased()
if value.contains("pizza") { return "takeoutbag.and.cup.and.straw" }
if value.contains("lanche") || value.contains("hamburg") || value.contains("burger") { return "fork.knife" }
if value.contains("cafe") || value.contains("breakfast") { return "sun.max" }
if value.contains("doce") || value.contains("sobremesa") || value.contains("dessert") { return "cup.and.saucer" }
return "storefront"
}
@MainActor
func resolveCoordinates(forceRefresh: Bool) async -> (Double, Double)? {
guard hasConfiguredAddress() else {
return nil
}
// If user selected/saved an address, always trust its coordinates.
// This avoids overriding the chosen city with current device GPS.
if let lat = appState.address.latitude, let lng = appState.address.longitude {
return (lat, lng)
}
if !forceRefresh, let cached = LocationService.shared.cachedLocation() {
appState.address.latitude = cached.0
appState.address.longitude = cached.1
return cached
}
// Fallback to device location only when no address coordinates are available.
let deviceCoordinate = await LocationService.shared.requestLocationAsync(timeoutSeconds: 3)
if let deviceCoordinate {
appState.address.latitude = deviceCoordinate.0
appState.address.longitude = deviceCoordinate.1
}
return deviceCoordinate
}
func hasConfiguredAddress() -> Bool {
if appState.address.selectedId != nil {
return true
}
let normalized = appState.address.display
.trimmingCharacters(in: .whitespacesAndNewlines)
.lowercased()
return normalized.isEmpty == false && normalized != "defina seu endereco"
}
func storesUserMessage(_ error: Error) -> String {
if let service = error as? ApiServiceError {
return service.errorDescription ?? "Não foi possível carregar os estabelecimentos."
}
if let network = error as? NetworkError {
return network.errorDescription ?? "Não foi possível carregar os estabelecimentos."
}
return "Não foi possível carregar os estabelecimentos."
}
/// Anonymous store loading: no account, no coordinates just the
/// manually-picked state/city from the public locator. The BFF endpoint
/// has no category filter, so any category chip selection is applied
/// client-side via `filteredStores` (HomeView+Filtering.swift), same as
/// the multi-select filters already do.
@MainActor
func loadGuestStores(hadExistingStores: Bool, category: String?, refreshCategories: Bool) async {
guard let state = GuestLocationStore.shared.selectedState,
let city = GuestLocationStore.shared.selectedCity else {
isLoadingStores = false
stores = []
storesError = "Escolha um estado e cidade para visualizar os estabelecimentos."
appState.activeModal = .addressPicker
return
}
do {
let items = try await PublicLocationService.shared.fetchStores(state: state, city: city)
isLoadingStores = false
let mapped = items.map(StoreSummary.init(publicItem:))
stores = mapped
if refreshCategories || (category == nil && categories.count <= 1) {
await loadHomeCategories(withFallbackStores: mapped, forceRefresh: refreshCategories)
if categories.contains(where: { $0.id == selectedCategory }) == false {
selectedCategory = "all"
}
}
storesError = nil
} catch {
if isCancelledRequest(error) {
isLoadingStores = false
return
}
isLoadingStores = false
reportStoresLoadFailure(storesUserMessage(error), hadExistingStores: hadExistingStores)
}
}
}
extension StoreSummary {
/// Maps the public-locator DTO onto the same model HomeView already
/// renders distance/positiveReviews don't exist in that response.
init(publicItem: PublicStoreListItem) {
self.id = publicItem.id
self.name = publicItem.name ?? "Loja"
self.logo = publicItem.logo
self.cover = publicItem.cover
self.category = publicItem.category
self.rating = publicItem.rating
self.reviewsCount = publicItem.totalReviews
self.positiveReviews = nil
self.deliveryTime = publicItem.deliveryTime
self.deliveryFee = publicItem.deliveryFee
self.distance = nil
self.isOpen = publicItem.isOpen
self.statusLabel = publicItem.statusLabel
}
}

View File

@@ -0,0 +1,42 @@
import Foundation
extension HomeView {
@MainActor
func toggleFavoriteStore(storeId: String, storeName: String) async {
guard favoriteRequestStoreIds.contains(storeId) == false else { return }
guard appState.session.isAuthenticated else {
SnackbarCenter.shared.show(title: "Faça login para favoritar lojas.", style: .warning, icon: "person.crop.circle.badge.exclamationmark", duration: 2.5)
return
}
let isFavorite = appState.favorites.storeIds.contains(storeId)
favoriteRequestStoreIds.insert(storeId)
defer { favoriteRequestStoreIds.remove(storeId) }
do {
let response = try await ApiService().setStoreFavorite(storeId: storeId, isFavorite: isFavorite == false)
guard response.error == false, let result = response.result else {
let message = response.message ?? "Não foi possível atualizar seus favoritos."
SnackbarCenter.shared.show(title: message, style: .error, icon: "xmark.octagon.fill", duration: 3.5)
return
}
appState.favorites.storeIds = Set(result.favorites)
let successTitle = isFavorite
? "\(storeName) removida dos favoritos."
: "\(storeName) adicionada aos favoritos."
let successIcon = isFavorite ? "heart.slash.fill" : "heart.fill"
SnackbarCenter.shared.show(title: successTitle, style: .success, icon: successIcon, duration: 2.2)
} catch {
let message: String
if let networkError = error as? NetworkError {
message = networkError.errorDescription ?? "Não foi possível atualizar seus favoritos."
} else if let serviceError = error as? ApiServiceError {
message = serviceError.errorDescription ?? "Não foi possível atualizar seus favoritos."
} else {
message = "Não foi possível atualizar seus favoritos."
}
SnackbarCenter.shared.show(title: message, style: .error, icon: "xmark.octagon.fill", duration: 3.5)
}
}
}

View File

@@ -0,0 +1,217 @@
import Foundation
extension HomeView {
var filteredStores: [StoreSummary] {
let normalizedQuery = normalizeSearch(searchText)
var list = stores
if appState.homeFilters.selectedCategories.isEmpty == false {
let allowed = Set(appState.homeFilters.selectedCategories.map(normalizeSearch))
list = list.filter { store in
let category = normalizeSearch(store.category ?? "")
return allowed.contains(category)
}
}
if let tier = appState.homeFilters.selectedPriceTier {
list = list.filter { store in
guard let fee = store.deliveryFee else { return false }
return matchesPriceTier(fee: fee, tier: tier)
}
}
let maxDistance = appState.homeFilters.maxDistanceKm
list = list.filter { store in
guard let distance = store.distance else { return true }
return distance <= maxDistance
}
if normalizedQuery.isEmpty == false {
list = list.filter { store in
matchesSearch(store: store, query: normalizedQuery)
}
}
return sortStores(list, query: normalizedQuery)
}
var featuredStoresCards: [FeaturedStoreCardModel] {
Array(filteredStores.prefix(5)).map(mapStoreToCard)
}
var filteredStoreCards: [FeaturedStoreCardModel] {
let featuredIds = Set(filteredStores.prefix(5).map(\.id))
let remaining = filteredStores.filter { featuredIds.contains($0.id) == false }
return Array(remaining.prefix(20)).map(mapStoreToCard)
}
var emptyResultMessage: String {
if normalizeSearch(searchText).isEmpty == false {
return "Nenhum resultado para \"\(searchText.trimmingCharacters(in: .whitespacesAndNewlines))\"."
}
return "Nenhum estabelecimento encontrado com os filtros selecionados."
}
func mapStoreToCard(_ store: StoreSummary) -> FeaturedStoreCardModel {
let coverURL = resolveStoreMediaURL(store.cover)
let logoURL = resolveStoreMediaURL(store.logo)
return FeaturedStoreCardModel(
id: store.id,
name: store.name,
rating: store.rating ?? 0,
reviews: String(store.positiveReviews ?? store.reviewsCount ?? 0),
distance: formatDistance(store.distance),
deliveryFee: store.deliveryFee,
category: store.category ?? "Loja",
promoText: nil,
isFavorite: appState.favorites.storeIds.contains(store.id),
iconName: "storefront",
imageURL: logoURL ?? coverURL,
logoURL: logoURL,
coverURL: coverURL,
isOpen: store.isOpen ?? true,
statusLabel: store.statusLabel
)
}
func resolveStoreMediaURL(_ raw: String?) -> String? {
ImageSourceResolver.resolve(raw)
}
var profilePictureURL: String? {
resolveStoreMediaURL(appState.profile.profilePicture)
}
func formatDistance(_ distance: Double?) -> String {
guard let distance else { return "Distância indisponível" }
if distance >= 1 {
return String(format: "%.1f km", distance)
}
return "\(Int(distance * 1000)) m"
}
func scheduleSearchIndexUpdate() {
searchDebounceToken += 1
let token = searchDebounceToken
Task {
try? await Task.sleep(nanoseconds: 220_000_000)
guard token == searchDebounceToken else { return }
await loadProductIndexForSearchIfNeeded()
}
}
@MainActor
func loadProductIndexForSearchIfNeeded() async {
let query = normalizeSearch(searchText)
guard query.isEmpty == false else { return }
let candidates = filteredStores
.filter { productSearchIndexByStoreId[$0.id] == nil }
.prefix(10)
guard candidates.isEmpty == false else { return }
await withTaskGroup(of: (String, [String]?).self) { group in
for store in candidates {
group.addTask {
do {
let response = try await ApiService().storeCatalog(storeId: store.id)
let products = response.result?.flatMap(\.products) ?? []
let names = products.map(\.name)
return (store.id, names)
} catch {
return (store.id, nil)
}
}
}
for await result in group {
let names = result.1 ?? []
productSearchIndexByStoreId[result.0] = names
}
}
}
func normalizeSearch(_ value: String) -> String {
value
.trimmingCharacters(in: .whitespacesAndNewlines)
.folding(options: [.diacriticInsensitive, .caseInsensitive], locale: .current)
.lowercased()
}
func matchesSearch(store: StoreSummary, query: String) -> Bool {
let storeName = normalizeSearch(store.name)
if storeName.contains(query) {
return true
}
let category = normalizeSearch(store.category ?? "")
if category.contains(query) {
return true
}
let products = productSearchIndexByStoreId[store.id] ?? []
return products.contains { normalizeSearch($0).contains(query) }
}
func sortStores(_ list: [StoreSummary], query: String) -> [StoreSummary] {
switch appState.homeFilters.sortOption {
case .relevance:
return list.sorted { lhs, rhs in
let lhsScore = relevanceScore(for: lhs, query: query)
let rhsScore = relevanceScore(for: rhs, query: query)
if lhsScore != rhsScore {
return lhsScore > rhsScore
}
return (lhs.distance ?? .greatestFiniteMagnitude) < (rhs.distance ?? .greatestFiniteMagnitude)
}
case .rating:
return list.sorted { ($0.rating ?? 0) > ($1.rating ?? 0) }
case .deliveryTime:
return list.sorted { estimatedDeliveryMinutes($0.deliveryTime) < estimatedDeliveryMinutes($1.deliveryTime) }
case .price:
return list.sorted { ($0.deliveryFee ?? .greatestFiniteMagnitude) < ($1.deliveryFee ?? .greatestFiniteMagnitude) }
}
}
func relevanceScore(for store: StoreSummary, query: String) -> Double {
guard query.isEmpty == false else {
let positive = Double(store.positiveReviews ?? store.reviewsCount ?? 0)
return positive + (store.rating ?? 0) * 10
}
let name = normalizeSearch(store.name)
let category = normalizeSearch(store.category ?? "")
let products = productSearchIndexByStoreId[store.id] ?? []
var score = 0.0
if name.hasPrefix(query) { score += 200 }
if name.contains(query) { score += 120 }
if category.contains(query) { score += 70 }
if products.contains(where: { normalizeSearch($0).contains(query) }) { score += 90 }
score += (store.rating ?? 0) * 10
score += Double(store.positiveReviews ?? store.reviewsCount ?? 0) * 0.02
return score
}
func estimatedDeliveryMinutes(_ value: String?) -> Int {
guard let value else { return Int.max }
let digits = value.compactMap { $0.isNumber ? String($0) : " " }.joined()
let parts = digits
.split(separator: " ")
.compactMap { Int($0) }
if let min = parts.min() {
return min
}
return Int.max
}
func matchesPriceTier(fee: Double, tier: HomePriceTier) -> Bool {
switch tier {
case .low: return fee <= 5
case .medium: return fee > 5 && fee <= 10
case .high: return fee > 10 && fee <= 20
case .veryHigh: return fee > 20
}
}
}

View File

@@ -0,0 +1,573 @@
import SwiftUI
#if os(iOS)
import LCEssentials
import UIKit
#endif
struct HomeView: View {
@Binding var appState: AppState
@Binding var selectedTab: MainTab
@State var searchText = ""
@State var selectedCategory = "all"
@State var categories: [CategoryModel] = [
.init(id: "all", title: "Todas", systemIcon: "line.3.horizontal.decrease.circle", emojiIcon: nil)
]
@State var scrollOffset: CGFloat = 0
@State var hasRequestedLocation = false
@State var isLoadingStores = false
@State var storesError: String? = nil
@State var stores: [StoreSummary] = []
@State var productSearchIndexByStoreId: [String: [String]] = [:]
@State var searchDebounceToken = 0
@State var favoriteRequestStoreIds: Set<String> = []
private let specials: [SpecialOfferCardModel] = [
// .init(id: "ddddd", title: "Get 50% OFF", subtitle: "For your first order", colors: [Color(hex: "#1E6B43"), Color(hex: "#58A56C")]),
// .init(id: "eeeee", title: "Enjoy spicy day", subtitle: "Delivery in 20 min", colors: [Color(hex: "#F04B3E"), Color(hex: "#FF8C42")])
]
private let headerExpandedHeight: CGFloat = 240
private let headerCollapsedHeight: CGFloat = 120
private let contentTopSpacing: CGFloat = 18
private let contentBottomSpacing: CGFloat = 120
var body: some View {
let collapseProgress = clamp(value: scrollOffset / (headerExpandedHeight - headerCollapsedHeight), lower: 0, upper: 1)
let headerHeight = headerExpandedHeight - (headerExpandedHeight - headerCollapsedHeight) * collapseProgress
return ZStack(alignment: .top) {
ScrollView(showsIndicators: false) {
contentStack
.padding(.top, headerExpandedHeight + contentTopSpacing)
.padding(.bottom, contentBottomSpacing)
}
.refreshable {
// See StoreDetailView's .refreshable for why this runs in
// its own unstructured Task: SwiftUI can cancel
// .refreshable's own wrapping Task independent of whether
// the network call is still legitimately in flight, and
// that cancellation was being silently swallowed by
// isCancelledRequest awaiting Task.value decouples the
// real work from that premature cancellation.
await Task {
await bootstrapStoresFlow(
forceNetworkRefresh: true,
category: selectedCategoryQueryValue,
refreshCategories: true
)
}.value
}
.appNamedCoordinateSpace(HomeScrollCoordinateSpace.name)
// ignoresSafeArea lives here, on the header only not on the
// .refreshable ScrollView above. Applying it to an ancestor of
// a .refreshable view (or the view itself) breaks the native
// pull-to-refresh spinner's positioning, rendering it invisible
// even though the gesture still fires the refresh closure.
header(collapseProgress: collapseProgress, height: headerHeight)
.frame(maxWidth: .infinity, alignment: .top)
.ignoresSafeArea(edges: .top)
}
.background(AppColors.backgroundLight)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
.onAppear {
if hasRequestedLocation == false {
hasRequestedLocation = true
Task {
await bootstrapStoresFlow(refreshCategories: true)
}
}
}
.onChange(of: addressCacheScope) { _, _ in
guard hasRequestedLocation else { return }
Task {
await bootstrapStoresFlow(
forceLocationRefresh: true,
category: selectedCategoryQueryValue,
refreshCategories: true
)
}
}
.onChange(of: searchText) { _, _ in
scheduleSearchIndexUpdate()
}
.onChange(of: appState.homeFilters.sortOption) { _, _ in scheduleSearchIndexUpdate() }
.onChange(of: appState.homeFilters.selectedCategories) { _, _ in scheduleSearchIndexUpdate() }
.onChange(of: appState.homeFilters.selectedPriceTier) { _, _ in scheduleSearchIndexUpdate() }
.onChange(of: appState.homeFilters.maxDistanceKm) { _, _ in scheduleSearchIndexUpdate() }
}
private var contentStack: some View {
VStack(spacing: 24) {
scrollOffsetObserver
// The collapsing header is a separate overlay drawn on top of
// this ScrollView in the ZStack above, which visually covers
// the native pull-to-refresh spinner's position. This gives
// refresh feedback that's actually visible, right below the
// header, instead of relying on a spinner hidden behind it.
if isLoadingStores && stores.isEmpty == false {
HStack(spacing: 8) {
ProgressView()
Text("Atualizando...")
.font(AppTypography.caption)
.foregroundStyle(AppColors.textMuted)
}
.frame(maxWidth: .infinity)
}
categoriesSection
section(title: "Featured") {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 16) {
ForEach(featuredStoresCards) { store in
NavigationLink {
storeDestination(for: store)
} label: {
FeaturedStoreCard(
store: store,
onFavoriteToggle: {
Task {
await toggleFavoriteStore(storeId: store.id, storeName: store.name)
}
}
)
.frame(width: 190)
}
.buttonStyle(.plain)
}
}
.padding(.horizontal, 20)
}
}
if appState.featureFlags.isEnabled("at.promo") {
section(title: "#PediPromo") {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 16) {
ForEach(specials) { item in
SpecialOfferCard(model: item)
.frame(width: 260, height: 120)
}
}
.padding(.horizontal, 20)
}
}
}
section(title: "Pertinho de você") {
// Once we have stores loaded, keep showing them regardless of
// a subsequent refresh's isLoadingStores/storesError state
// a failed or in-flight pull-to-refresh must never hide
// already-loaded content.
if filteredStoreCards.isEmpty == false {
storeCardsList
} else if isLoadingStores {
HStack {
ProgressView()
Text("Buscando estabelecimentos próximos...")
.font(AppTypography.body)
.foregroundStyle(AppColors.textMuted)
}
.padding(.horizontal, 20)
} else if let storesError {
VStack(alignment: .leading, spacing: 10) {
Text(storesError)
.font(AppTypography.body)
.foregroundStyle(AppColors.textMuted)
Button("Tentar novamente") {
Task {
await bootstrapStoresFlow(
forceLocationRefresh: true,
category: selectedCategoryQueryValue,
refreshCategories: true
)
}
}
.buttonStyle(.plain)
.font(AppTypography.heading3)
.foregroundStyle(AppColors.primary)
}
.padding(.horizontal, 20)
} else {
Text(emptyResultMessage)
.font(AppTypography.body)
.foregroundStyle(AppColors.textMuted)
.padding(.horizontal, 20)
}
}
}
}
@ViewBuilder
private func storeDestination(for store: FeaturedStoreCardModel) -> some View {
StoreDetailView(
storeId: store.id,
storeName: store.name,
storeCoverURL: store.coverURL,
storeLogoURL: store.logoURL,
storeCategory: store.category,
storeRating: store.rating,
storeDistance: store.distance,
storeDeliveryFee: store.deliveryFee,
appState: $appState
)
}
@ViewBuilder
private var storeCardsList: some View {
VStack(spacing: 16) {
ForEach(filteredStoreCards) { store in
NavigationLink {
storeDestination(for: store)
} label: {
FeaturedStoreCard(
store: store,
onFavoriteToggle: {
Task {
await toggleFavoriteStore(storeId: store.id, storeName: store.name)
}
}
)
}
.buttonStyle(.plain)
}
}
.padding(.horizontal, 20)
}
private func header(collapseProgress: CGFloat, height: CGFloat) -> some View {
let titleOpacity = 1 - clamp(value: collapseProgress * 1.2, lower: 0, upper: 1)
let topRowOpacity = 1 - clamp(value: collapseProgress * 1.4, lower: 0, upper: 1)
return ZStack(alignment: .top) {
RoundedRectangle(cornerRadius: 32, style: .continuous)
.fill(AppColors.primary)
.frame(height: height)
.overlay(headerRings.opacity(1 - collapseProgress), alignment: .topTrailing)
VStack(alignment: .leading, spacing: 16) {
Spacer().frame(height: 20)
HStack(alignment: .center, spacing: 12) {
Button {
selectedTab = .profile
} label: {
Circle()
.fill(AppColors.brandSoft)
.frame(width: 40, height: 40)
.overlay {
if let profilePictureURL {
AsyncStoreImage(imageURL: profilePictureURL)
.frame(width: 36, height: 36)
.clipShape(Circle())
} else {
Image(systemName: "person.fill")
.foregroundStyle(AppColors.brandDark)
}
}
}
.buttonStyle(.plain)
VStack(alignment: .center, spacing: 4) {
Text("ENTREGAR EM:")
.font(AppTypography.overline)
.tracking(AppTypography.captionLetterSpacing)
.foregroundStyle(AppColors.brandSoft)
.multilineTextAlignment(.center)
Button {
appState.address.onboardingMessage = nil
appState.activeModal = .addressPicker
} label: {
HStack(spacing: 6) {
Text(appState.address.display)
.font(AppTypography.heading3)
.foregroundStyle(AppColors.textInverse)
Image(systemName: "chevron.down")
.font(.caption)
.foregroundStyle(AppColors.brandSoft)
}
}
.buttonStyle(.plain)
}
.frame(maxWidth: .infinity, alignment: .center)
Circle()
.fill(Color.white.opacity(0.18))
.frame(width: 40, height: 40)
.overlay(
Image(systemName: "bell")
.foregroundStyle(AppColors.textInverse)
)
}
.opacity(topRowOpacity)
.offset(y: collapseProgress * -12)
if UIDevice().modelName.lowercased().contains("se") || UIDevice().modelName.lowercased().contains("16e") {
Text("O que vai querer \npedir hoje?")
.font(AppTypography.heading2)
.foregroundStyle(AppColors.textInverse)
.opacity(titleOpacity)
.offset(y: collapseProgress * -20)
} else {
Text("O que vai querer \npedir hoje?")
.font(AppTypography.heading2)
.foregroundStyle(AppColors.textInverse)
.opacity(titleOpacity)
.offset(y: collapseProgress * -20)
}
SearchBar(placeholder: "Search menu, restaurant or craving", text: $searchText) {
appState.homeFilters.availableCategories = categories
.filter { $0.id.lowercased() != "all" }
.map(\.title)
appState.activeModal = .filters
}
.offset(y: collapseProgress * -120)
}
.padding(.horizontal, 20)
.padding(.top, 18)
}
}
private func section<Content: View>(title: String, @ViewBuilder content: () -> Content) -> some View {
VStack(alignment: .leading, spacing: 16) {
Text(title)
.font(AppTypography.heading2)
.foregroundStyle(AppColors.textPrimary)
.padding(.horizontal, 20)
content()
}
}
private var categoriesSection: some View {
VStack(alignment: .leading, spacing: 12) {
Text("Categories")
.font(AppTypography.heading2)
.foregroundStyle(AppColors.textPrimary)
.padding(.horizontal, 20)
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 12) {
ForEach(categories) { category in
CategoryChip(
title: category.title,
systemIcon: category.systemIcon,
emojiIcon: category.emojiIcon,
isActive: category.id == selectedCategory
)
.onTapGesture {
guard category.id != selectedCategory else { return }
selectedCategory = category.id
Task {
await bootstrapStoresFlow(category: category.id.lowercased() == "all" ? nil : category.title)
}
}
}
}
.padding(.horizontal, 20)
}
}
}
private var headerRings: some View {
ZStack {
Circle()
.stroke(Color.white.opacity(0.08), lineWidth: 1)
.frame(width: 180, height: 180)
.offset(x: 40, y: -10)
Circle()
.stroke(Color.white.opacity(0.08), lineWidth: 1)
.frame(width: 130, height: 130)
.offset(x: 70, y: 10)
}
}
@MainActor
private func bootstrapStoresFlow(
forceLocationRefresh: Bool = false,
forceNetworkRefresh: Bool = false,
category: String? = nil,
refreshCategories: Bool = false
) async {
if isLoadingStores { return }
// A refresh (pull-to-refresh) that fails must never wipe the list
// the user is already looking at only a first load with nothing
// yet loaded is allowed to show a blocking error state.
let hadExistingStores = stores.isEmpty == false
isLoadingStores = true
if hadExistingStores == false {
storesError = nil
}
// Anonymous browsing has no account address/coordinates the public
// locator uses a manually-picked state/city instead (geolocation is
// out of scope for that flow, see public-store-locator-sdd.md).
guard appState.session.isAuthenticated else {
await loadGuestStores(hadExistingStores: hadExistingStores, category: category, refreshCategories: refreshCategories)
return
}
let coordinate = await resolveCoordinates(forceRefresh: forceLocationRefresh)
let hasAddress = hasConfiguredAddress()
if coordinate == nil && hasAddress == false {
isLoadingStores = false
stores = []
storesError = "Defina um endereço para visualizar os estabelecimentos mais próximos."
appState.address.onboardingMessage = "Vamos criar um endereço para poder visualizar os estabelecimentos mais próximos de você?"
appState.activeModal = .addressPicker
return
}
do {
let storesCacheKey = homeStoresCacheKey(
lat: coordinate?.0,
lng: coordinate?.1,
category: category
)
if forceLocationRefresh == false,
forceNetworkRefresh == false,
let cachedStores: [StoreSummary] = AppContentCache.shared.value(for: storesCacheKey, as: [StoreSummary].self) {
isLoadingStores = false
stores = cachedStores
#if os(iOS)
for store in cachedStores {
printLog(
title: "LOGO HOME",
msg: "storeId=\(store.id) | storeName=\(store.name) | logoRaw=\(store.logo ?? "nil")"
)
}
#endif
AppContentCache.shared.set(cachedStores, for: AppCacheKey.homeStoresLatestSnapshot, ttl: AppCacheTTL.twoHours)
if refreshCategories || (category == nil && categories.count <= 1) {
await loadHomeCategories(
withFallbackStores: cachedStores,
forceRefresh: forceLocationRefresh || forceNetworkRefresh
)
if categories.contains(where: { $0.id == selectedCategory }) == false {
selectedCategory = "all"
}
}
storesError = nil
return
}
let response = try await ApiService().listStores(
lat: coordinate?.0,
lng: coordinate?.1,
category: category
)
isLoadingStores = false
if response.error {
reportStoresLoadFailure(
response.message ?? "Não foi possível carregar os estabelecimentos.",
hadExistingStores: hadExistingStores
)
return
}
let results = response.result ?? []
stores = results
#if os(iOS)
for store in results {
printLog(
title: "LOGO HOME",
msg: "storeId=\(store.id) | storeName=\(store.name) | logoRaw=\(store.logo ?? "nil")"
)
}
#endif
AppContentCache.shared.set(results, for: storesCacheKey, ttl: AppCacheTTL.homeStores)
AppContentCache.shared.set(results, for: AppCacheKey.homeStoresLatestSnapshot, ttl: AppCacheTTL.twoHours)
if refreshCategories || (category == nil && categories.count <= 1) {
await loadHomeCategories(
withFallbackStores: results,
forceRefresh: forceLocationRefresh || forceNetworkRefresh
)
if categories.contains(where: { $0.id == selectedCategory }) == false {
selectedCategory = "all"
}
}
storesError = nil
} catch {
if isCancelledRequest(error) {
isLoadingStores = false
return
}
isLoadingStores = false
reportStoresLoadFailure(storesUserMessage(error), hadExistingStores: hadExistingStores)
}
}
func reportStoresLoadFailure(_ message: String, hadExistingStores: Bool) {
if hadExistingStores {
SnackbarCenter.shared.show(title: message, style: .warning, icon: "exclamationmark.triangle.fill", duration: 3.0)
} else {
stores = []
storesError = message
}
}
@ViewBuilder
private var scrollOffsetObserver: some View {
ScrollOffsetObserver { y in
// Use only upward displacement for collapse and ignore top bounce.
let normalized = max(0, y)
scrollOffset = normalized
}
.frame(width: 0, height: 0)
}
private var addressCacheScope: String {
let selected = appState.address.selectedId ?? "nil"
let display = appState.address.display
.trimmingCharacters(in: .whitespacesAndNewlines)
.lowercased()
let lat = appState.address.latitude.map(formatCoordinateScope) ?? "nil"
let lng = appState.address.longitude.map(formatCoordinateScope) ?? "nil"
return "\(selected)|\(display)|\(lat)|\(lng)"
}
private func homeStoresCacheKey(lat: Double?, lng: Double?, category: String?) -> String {
let normalizedCategory = category?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() ?? "all"
let latKey = lat.map(formatCoordinateCache) ?? "nil"
let lngKey = lng.map(formatCoordinateCache) ?? "nil"
return "stores:\(latKey):\(lngKey):\(normalizedCategory)"
}
private func formatCoordinateScope(_ value: Double) -> String {
String((value * 100_000).rounded() / 100_000)
}
private func formatCoordinateCache(_ value: Double) -> String {
String((value * 10_000).rounded() / 10_000)
}
private var selectedCategoryQueryValue: String? {
guard selectedCategory.lowercased() != "all" else { return nil }
guard let selected = categories.first(where: { $0.id == selectedCategory }) else { return nil }
return selected.title
}
func isCancelledRequest(_ error: Error) -> Bool {
if error is CancellationError {
return true
}
if let networkError = error as? NetworkError {
switch networkError {
case .cancelled:
return true
case .transportError(let message):
let normalized = message.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
return normalized.contains("cancel")
default:
break
}
}
return error.localizedDescription.lowercased().contains("cancel")
}
}

View File

@@ -0,0 +1,65 @@
import SwiftUI
struct CategoryModel: Identifiable {
let id: String
let title: String
let systemIcon: String?
let emojiIcon: String?
}
struct CategoryChip: View {
let title: String
let systemIcon: String?
let emojiIcon: String?
let isActive: Bool
var body: some View {
HStack(spacing: 8) {
if let emojiIcon, emojiIcon.isEmpty == false {
Text(emojiIcon)
.font(.body)
} else if let systemIcon, systemIcon.isEmpty == false {
Image(systemName: systemIcon)
.font(.caption)
}
Text(title)
.font(AppTypography.heading3)
}
.foregroundStyle(isActive ? AppColors.textInverse : AppColors.textPrimary)
.padding(.horizontal, 16)
.padding(.vertical, 10)
.background(isActive ? AppColors.primary : AppColors.surface)
.clipShape(Capsule())
}
}
struct SearchBar: View {
let placeholder: String
@Binding var text: String
var onFilterTap: () -> Void = {}
var body: some View {
HStack(spacing: 12) {
Image(systemName: "magnifyingglass")
.foregroundStyle(AppColors.textMuted)
TextField(placeholder, text: $text)
.appNoAutoCap()
.foregroundStyle(AppColors.textPrimary)
.tint(AppColors.textPrimary)
Spacer()
Button(action: onFilterTap) {
Image(systemName: "slider.horizontal.3")
.foregroundStyle(AppColors.textMuted)
}
.buttonStyle(.plain)
}
.padding(.horizontal, 16)
.frame(height: 52)
.background(AppColors.surface)
.clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous))
}
}
func clamp(value: CGFloat, lower: CGFloat, upper: CGFloat) -> CGFloat {
min(max(value, lower), upper)
}

View File

@@ -0,0 +1,89 @@
import SwiftUI
struct MainTabView: View {
@Binding var selectedTab: MainTab
let tokenStore: TokenStore
@Binding var appState: AppState
let enterAuth: () -> Void
var body: some View {
ZStack(alignment: .bottom) {
Group {
switch selectedTab {
case .home:
NavigationStack {
HomeView(appState: $appState, selectedTab: $selectedTab)
}
case .cart:
NavigationStack {
CartView(appState: $appState, selectedTab: $selectedTab, enterAuth: enterAuth)
}
case .profile:
NavigationStack {
if appState.session.isAuthenticated {
ProfileView(selectedTab: $selectedTab, tokenStore: tokenStore, appState: $appState, enterAuth: enterAuth)
} else {
ProfileLoggedOutView(enterAuth: enterAuth)
}
}
}
}
customTabBar
}
}
private var customTabBar: some View {
HStack(spacing: 12) {
tabBarButton(tab: .home, title: "Home", icon: "house.fill")
tabBarButton(tab: .cart, title: "Carrinho", icon: "cart.fill", badgeCount: appState.cart.totalItems)
tabBarButton(tab: .profile, title: "Perfil", icon: "person.fill")
}
.padding(.horizontal, 14)
.padding(.vertical, 10)
.background(
RoundedRectangle(cornerRadius: 30, style: .continuous)
.fill(AppColors.surface.opacity(0.95))
)
.padding(.horizontal, 18)
.padding(.bottom, 10)
.shadow(color: AppShadow.soft.color, radius: AppShadow.soft.radius, y: AppShadow.soft.y)
}
private func tabBarButton(tab: MainTab, title: String, icon: String, badgeCount: Int = 0) -> some View {
let isActive = selectedTab == tab
return Button {
selectedTab = tab
} label: {
HStack(spacing: 8) {
ZStack(alignment: .topTrailing) {
Image(systemName: icon)
.font(.system(size: 18, weight: .semibold))
if badgeCount > 0 {
Text("\(min(badgeCount, 99))")
.font(.system(size: 9, weight: .bold))
.foregroundStyle(Color.white)
.padding(.horizontal, 4)
.padding(.vertical, 2)
.background(Color.red)
.clipShape(Capsule())
.offset(x: 9, y: -8)
}
}
if isActive {
Text(title)
.font(AppTypography.heading3)
}
}
.foregroundStyle(isActive ? AppColors.textInverse : AppColors.textPrimary)
.padding(.horizontal, 18)
.padding(.vertical, 10)
.background(
Capsule()
.fill(isActive ? AppColors.primary : Color.clear)
)
}
.buttonStyle(.plain)
}
}

View File

@@ -0,0 +1,622 @@
import SwiftUI
struct OrderDetailsView: View {
let order: PublicOrderResult
let orderId: String
let initialShortId: String?
@Binding var appState: AppState
@Environment(\.dismiss) var dismiss
@Environment(\.openURL) var openURL
@State private var storeContactPhone: String? = nil
@State private var resolvedStoreLogoURL: String? = nil
@State private var showCallAlert = false
@State private var navigateToStore = false
@State private var showClearCartAlert = false
var body: some View {
ScrollView(showsIndicators: false) {
VStack(spacing: 14) {
screenHeader
statusCard
storeCard
itemsCard
totalsCard
if hasAddressInfo {
addressCard
}
helpFooter
}
.padding(.horizontal, 20)
.padding(.top, 14)
.padding(.bottom, UIDevice.bottomNotch)
}
.background(AppColors.backgroundLight)
.appHiddenNavigationBar()
.navigationBarBackButtonHidden(true)
.navigationDestination(isPresented: $navigateToStore) {
if let storeId = order.storeId, storeId.isEmpty == false {
StoreDetailView(
storeId: storeId,
storeName: order.storeName ?? "Loja",
storeCoverURL: nil,
storeLogoURL: order.storeLogoURL,
storeCategory: nil,
storeRating: nil,
storeDistance: nil,
storeDeliveryFee: nil,
appState: $appState
)
}
}
.alert("Ligar para a loja?", isPresented: $showCallAlert) {
Button("Ligar para \(order.storeName ?? "a loja")") {
if let phone = storeContactPhone {
openTel(phone)
}
}
Button("Cancelar", role: .cancel) {}
} message: {
Text("WhatsApp não encontrado. Deseja ligar para \(order.storeName ?? "a loja")?")
}
.task {
await loadStoreContactPhone()
}
.alert("Substituir carrinho?", isPresented: $showClearCartAlert) {
Button("Limpar e adicionar", role: .destructive) {
applyReorder(clearFirst: true)
}
Button("Cancelar", role: .cancel) {}
} message: {
Text("Seu carrinho tem itens de \(appState.cart.storeName ?? appState.cart.storeId ?? "outra loja"). Deseja limpar e adicionar itens de \(order.storeName ?? "esta loja")?")
}
.appBottomSafeAreaInset {
VStack {
reorderButton
.padding(.horizontal, 20)
.padding(.top, 10)
.padding(.bottom, UIDevice.bottomNotch + 60)
}
.background(AppColors.backgroundLight.opacity(0.94))
}
}
private var screenHeader: some View {
ZStack {
Text("Detalhes do Pedido")
.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 var statusCard: some View {
HStack(spacing: 14) {
Circle()
.fill(AppColors.brandSoft)
.frame(width: 54, height: 54)
.overlay(
Image(systemName: statusIcon)
.font(.system(size: 22, weight: .bold))
.foregroundStyle(statusColor)
)
VStack(alignment: .leading, spacing: 2) {
Text(statusTitle)
.font(AppTypography.heading3)
.foregroundStyle(AppColors.textPrimary)
Text(statusDateText)
.font(AppTypography.body)
.foregroundStyle(AppColors.textMuted)
if let reason = cancellationReasonText {
Text(reason)
.font(AppTypography.caption)
.foregroundStyle(AppColors.textMuted)
.padding(.top, 2)
}
if let addr = deliveryAddressSummary {
HStack(spacing: 4) {
Image(systemName: "mappin.circle.fill")
.font(.system(size: 11))
.foregroundStyle(AppColors.textMuted)
Text(addr)
.font(AppTypography.caption)
.foregroundStyle(AppColors.textMuted)
.lineLimit(2)
}
.padding(.top, 2)
}
}
Spacer(minLength: 0)
}
.padding(16)
.background(AppColors.surface)
.clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous))
}
private var storeCard: some View {
Button {
if order.storeId?.isEmpty == false {
navigateToStore = true
}
} label: {
HStack(spacing: 12) {
AsyncStoreImage(imageURL: resolvedMediaURL(resolvedStoreLogoURL ?? order.storeLogoURL))
.frame(width: 54, height: 54)
.clipShape(Circle())
.background(AppColors.brandSoft, in: Circle())
VStack(alignment: .leading, spacing: 4) {
Text(order.storeName?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false ? (order.storeName ?? "Loja") : "Loja")
.font(AppTypography.heading2)
.foregroundStyle(AppColors.textPrimary)
Text(storeSubtitle)
.font(AppTypography.body)
.foregroundStyle(AppColors.textMuted)
}
Spacer(minLength: 0)
if order.storeId?.isEmpty == false {
Text("Ver loja")
.font(AppTypography.body)
.foregroundStyle(Color(hex: "#A5D645"))
}
}
.padding(16)
.background(AppColors.surface)
.clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous))
}
.buttonStyle(.plain)
}
private var itemsCard: some View {
VStack(alignment: .leading, spacing: 14) {
Text("Itens do Pedido")
.font(AppTypography.heading2)
.foregroundStyle(AppColors.textPrimary)
ForEach(order.items) { item in
HStack(alignment: .top, spacing: 12) {
Text("\(max(1, item.qty ?? 1))")
.font(AppTypography.heading3)
.foregroundStyle(AppColors.textPrimary)
.frame(width: 30, height: 30)
.background(AppColors.backgroundLight)
.clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
VStack(alignment: .leading, spacing: 2) {
Text(item.name?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false ? (item.name ?? "Item") : "Item")
.font(AppTypography.heading3)
.foregroundStyle(AppColors.textPrimary)
}
Spacer(minLength: 0)
if let price = item.price {
Text(formatCurrency(price))
.font(AppTypography.heading3)
.foregroundStyle(AppColors.textPrimary)
}
}
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(16)
.background(AppColors.surface)
.clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous))
}
private var totalsCard: some View {
VStack(alignment: .leading, spacing: 10) {
Text("Resumo de Valores")
.font(AppTypography.heading2)
.foregroundStyle(AppColors.textPrimary)
HStack {
Text("Subtotal")
.font(AppTypography.body)
.foregroundStyle(AppColors.textMuted)
Spacer()
Text(formatCurrency(subtotalValue))
.font(AppTypography.body)
.foregroundStyle(AppColors.textMuted)
}
HStack {
Text("Taxa de entrega")
.font(AppTypography.body)
.foregroundStyle(AppColors.textMuted)
Spacer()
Text(formatCurrency(deliveryFeeValue))
.font(AppTypography.body)
.foregroundStyle(AppColors.textMuted)
}
HStack {
Text("Desconto")
.font(AppTypography.body)
.foregroundStyle(AppColors.textMuted)
Spacer()
Text("- \(formatCurrency(discountValue))")
.font(AppTypography.body)
.foregroundStyle(Color(hex: "#18A957"))
}
Divider()
HStack {
Text("Total")
.font(AppTypography.heading2)
.foregroundStyle(AppColors.textPrimary)
Spacer()
Text(formatCurrency(totalValue))
.font(AppTypography.heading1)
.foregroundStyle(AppColors.textPrimary)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(16)
.background(AppColors.surface)
.clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous))
}
private var addressCard: some View {
VStack(alignment: .leading, spacing: 12) {
HStack(spacing: 10) {
Circle()
.fill(AppColors.backgroundLight)
.frame(width: 34, height: 34)
.overlay(
Image(systemName: "mappin.circle.fill")
.font(.system(size: 18, weight: .semibold))
.foregroundStyle(AppColors.textMuted)
)
Text("ENDEREÇO DE ENTREGA")
.font(AppTypography.caption)
.foregroundStyle(AppColors.textMuted)
Spacer(minLength: 0)
}
Text(deliveryAddressLine)
.font(AppTypography.body)
.foregroundStyle(AppColors.textPrimary)
if deliveryAddressLine2.isEmpty == false {
Text(deliveryAddressLine2)
.font(AppTypography.body)
.foregroundStyle(AppColors.textMuted)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(16)
.background(AppColors.surface)
.clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous))
}
private var reorderButton: some View {
Button("Pedir Novamente") {
reorder()
}
.font(AppTypography.heading2)
.foregroundStyle(Color(hex: "#0E1A06"))
.frame(maxWidth: .infinity, minHeight: 56)
.background(order.items.isEmpty ? Color(hex: "#C8F06E").opacity(0.45) : Color(hex: "#C8F06E"))
.clipShape(Capsule())
.buttonStyle(.plain)
.disabled(order.items.isEmpty)
}
private var helpFooter: some View {
Button {
handleHelpTap()
} label: {
Text("Precisa de ajuda com esse pedido?")
.font(AppTypography.body)
.foregroundStyle(Color(hex: "#A5D645"))
.frame(maxWidth: .infinity, alignment: .center)
.padding(.vertical, 8)
}
.buttonStyle(.plain)
}
private var subtotal: Double {
order.items.reduce(0) { partial, item in
partial + (Double(max(1, item.qty ?? 1)) * (item.price ?? 0))
}
}
private var subtotalValue: Double {
order.subtotal ?? subtotal
}
private var deliveryFeeValue: Double {
max(0, order.deliveryFee ?? 0)
}
private var discountValue: Double {
max(0, order.discount ?? 0)
}
private var totalValue: Double {
if let total = order.total {
return total
}
let calculated = subtotalValue + deliveryFeeValue - discountValue
return max(0, calculated)
}
private var hasAddressInfo: Bool {
deliveryAddressLine.isEmpty == false || deliveryAddressLine2.isEmpty == false
}
private var deliveryAddressLine: String {
guard let address = order.deliveryAddress else { return "" }
let street = normalizedText(address.street)
let number = normalizedText(address.number)
let base = [street, number].filter { $0.isEmpty == false }.joined(separator: ", ")
if base.isEmpty == false { return base }
return normalizedText(address.label)
}
private var deliveryAddressLine2: String {
guard let address = order.deliveryAddress else { return "" }
let neighborhood = normalizedText(address.neighborhood)
let city = normalizedText(address.city)
let state = normalizedText(address.state)
let zip = normalizedText(address.zip)
return [neighborhood, city, state, zip]
.filter { $0.isEmpty == false }
.joined(separator: "")
}
private var storeSubtitle: String {
if deliveryAddressLine2.isEmpty == false {
return deliveryAddressLine2
}
return "Pedido #\(displayOrderTitle)"
}
private var deliveryAddressSummary: String? {
if let full = order.fullAddress, full.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false {
return full.trimmingCharacters(in: .whitespacesAndNewlines)
}
let line1 = deliveryAddressLine
let line2 = deliveryAddressLine2
let combined = [line1, line2].filter { $0.isEmpty == false }.joined(separator: ", ")
return combined.isEmpty ? nil : combined
}
private var cancellationReasonText: String? {
guard statusTitle.contains("cancelado"),
let reason = order.cancellationReason,
reason.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false
else { return nil }
return "Motivo: \(reason.trimmingCharacters(in: .whitespacesAndNewlines))"
}
private var statusTitle: String {
let status = normalized(order.status)
if status.contains("CANCEL") { return "Pedido cancelado" }
if status.contains("COMPLETED") || status.contains("DELIVERED") {
return normalized(order.deliveryType ?? order.deliveryTypeLabel).contains("PICKUP") ? "Pedido retirado" : "Pedido concluído"
}
if status.contains("DELIVER") || status.contains("ROTA") { return "Pedido em rota" }
if status.contains("READY") { return "Pedido pronto" }
if status.contains("PREPAR") { return "Pedido em produção" }
return "Pedido confirmado"
}
private var statusDateText: String {
if let event = order.timeline.first,
let date = event.date, date.isEmpty == false {
let time = event.time.flatMap { $0.isEmpty ? nil : $0 }
let combined = time.map { "\(date) às \($0)" } ?? date
return "\(statusDatePrefix) \(combined)"
}
if let formatted = formatDate(order.updatedAt ?? order.createdAt) {
return "\(statusDatePrefix) \(formatted)"
}
return statusDatePrefix
}
private var statusDatePrefix: String {
if statusTitle.contains("cancelado") { return "Cancelado em" }
if statusTitle.contains("retirado") { return "Retirado em" }
if statusTitle.contains("concluído") { return "Entregue em" }
return "Atualizado em"
}
private var statusIcon: String {
statusTitle.contains("cancelado") ? "xmark" : "checkmark"
}
private var statusColor: Color {
statusTitle.contains("cancelado") ? Color.red : AppColors.primary
}
private var displayOrderTitle: String {
if let short = order.shortId, short.isEmpty == false { return short }
let orderIdValue = order.id.trimmingCharacters(in: .whitespacesAndNewlines)
if orderIdValue.isEmpty == false { return orderIdValue }
if let initialShortId, initialShortId.isEmpty == false { return initialShortId }
return String(orderId.prefix(6))
}
private func formatCurrency(_ value: Double) -> String {
String(format: "R$ %.2f", value).replacingOccurrences(of: ".", with: ",")
}
private func reorder() {
guard order.items.isEmpty == false else { return }
let cartStoreId = appState.cart.storeId ?? appState.cart.items.first?.storeId ?? ""
let orderStoreId = order.storeId ?? ""
let cartHasDifferentStore = cartStoreId.isEmpty == false
&& orderStoreId.isEmpty == false
&& cartStoreId != orderStoreId
&& appState.cart.items.isEmpty == false
if cartHasDifferentStore {
showClearCartAlert = true
} else {
applyReorder(clearFirst: false)
}
}
private func applyReorder(clearFirst: Bool) {
if clearFirst {
appState.cart.clear()
}
let storeId = order.storeId ?? ""
if appState.cart.storeId == nil || appState.cart.storeId?.isEmpty == true {
appState.cart.storeId = storeId
appState.cart.storeName = order.storeName
}
var addedCount = 0
for item in order.items {
let name = item.name?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
guard name.isEmpty == false else { continue }
let qty = max(1, item.qty ?? 1)
let price = item.price ?? 0
let cartItem = CartItemState(
id: UUID().uuidString,
productId: item.productId ?? item.id,
storeId: storeId,
name: name,
imageURL: nil,
details: nil,
addons: [],
quantity: qty,
unitPrice: price
)
appState.cart.add(item: cartItem)
addedCount += qty
}
let label = addedCount == 1 ? "1 item adicionado ao carrinho." : "\(addedCount) itens adicionados ao carrinho."
SnackbarCenter.shared.show(title: label, style: .success, icon: "cart.badge.plus", duration: 2.5)
}
private func formatDate(_ isoValue: String?) -> String? {
guard let isoValue, isoValue.isEmpty == false else { return nil }
let iso = ISO8601DateFormatter()
let optionSets: [ISO8601DateFormatter.Options] = [
[.withInternetDateTime, .withFractionalSeconds],
[.withInternetDateTime],
[.withFullDate, .withTime, .withColonSeparatorInTime],
[.withFullDate, .withTime, .withColonSeparatorInTime, .withTimeZone],
[.withFullDate]
]
var date: Date? = nil
for options in optionSets {
iso.formatOptions = options
if let d = iso.date(from: isoValue) {
date = d
break
}
}
if date == nil {
let fallback = DateFormatter()
fallback.locale = Locale(identifier: "en_US_POSIX")
for fmt in ["yyyy-MM-dd'T'HH:mm:ss.SSSZ", "yyyy-MM-dd'T'HH:mm:ssZ",
"yyyy-MM-dd HH:mm:ss", "yyyy-MM-dd"] {
fallback.dateFormat = fmt
if let d = fallback.date(from: isoValue) { date = d; break }
}
}
guard let date else { return nil }
let formatter = DateFormatter()
formatter.locale = Locale(identifier: "pt_BR")
formatter.dateFormat = "dd MMM, HH:mm"
return formatter.string(from: date)
}
@MainActor
private func loadStoreContactPhone() async {
if let inline = order.storePhone, inline.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false {
storeContactPhone = inline
}
guard let storeId = order.storeId?.trimmingCharacters(in: .whitespacesAndNewlines),
storeId.isEmpty == false else { return }
do {
let response = try await ApiService().storeInfo(storeId: storeId)
if response.error == false, let result = response.result {
let phone = (result.whatsapp ?? result.phone ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
if phone.isEmpty == false {
storeContactPhone = phone
}
if let logo = result.logo, logo.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false {
resolvedStoreLogoURL = logo
}
}
} catch {}
}
private func handleHelpTap() {
guard let phoneRaw = storeContactPhone,
phoneRaw.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false else {
SnackbarCenter.shared.show(
title: "Telefone da loja indisponível.",
style: .warning,
icon: "exclamationmark.triangle.fill",
duration: 2.8
)
return
}
if let waURL = makeWhatsAppURL(from: phoneRaw) {
openURL(waURL)
} else {
showCallAlert = true
}
}
private func openTel(_ phoneRaw: String) {
let digits = phoneRaw.filter(\.isNumber)
if digits.isEmpty { return }
if let url = URL(string: "tel://\(digits)") {
openURL(url)
}
}
private func makeWhatsAppURL(from phoneRaw: String) -> URL? {
var digits = phoneRaw.filter(\.isNumber)
if digits.isEmpty { return nil }
if digits.hasPrefix("0") { digits = String(digits.drop(while: { $0 == "0" })) }
if digits.hasPrefix("55") == false && (digits.count == 10 || digits.count == 11) {
digits = "55" + digits
}
guard digits.count >= 12 else { return nil }
return URL(string: "https://wa.me/\(digits)")
}
private func normalized(_ value: String?) -> String {
(value ?? "")
.folding(options: [.diacriticInsensitive, .caseInsensitive], locale: .current)
.uppercased()
.trimmingCharacters(in: .whitespacesAndNewlines)
}
private func resolvedMediaURL(_ raw: String?) -> String? {
ImageSourceResolver.resolve(raw)
}
private func normalizedText(_ value: String?) -> String {
(value ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
}
}

View File

@@ -0,0 +1,919 @@
import SwiftUI
import UIKit
private struct TrackingStep: Identifiable {
let id: String
let title: String
let subtitle: String
let time: String?
let isCompleted: Bool
let isActive: Bool
}
struct OrderTrackingView: View {
let orderId: String
let initialShortId: String?
var postOrderBack: (() -> Void)? = nil
@Environment(\.dismiss) var dismiss
@Environment(\.openURL) var openURL
@State var isLoading = true
@State var errorMessage: String? = nil
@State var order: PublicOrderResult? = nil
@State var storeContactPhone: String? = nil
@State var tracker = OrderRealtimeTracker()
@State var showCancellationReason = false
@State var reviewDraft: ReviewDraft? = nil
@State var didSaveReviewForCurrentOrder = false
@State var reviewSavedObserver: Any?
@State var showPushOptInAlert = false
var body: some View {
ScrollView(showsIndicators: false) {
VStack(spacing: 16) {
screenHeader
topHeader
orderTitleSection
statusBanner
timelineSection
placeholderCard
if shouldShowReviewButton {
reviewButton
} else {
contactButton
}
}
.padding(.horizontal, 20)
.padding(.top, 14)
.padding(.bottom, UIDevice.bottomNotch + 45)
}
.background(AppColors.backgroundLight)
.appHiddenNavigationBar()
.navigationBarBackButtonHidden(true)
.alert("Motivo do cancelamento", isPresented: $showCancellationReason) {
Button("Fechar", role: .cancel) {}
} message: {
Text(cancellationReasonText)
}
.alert("Ative as notificações", isPresented: $showPushOptInAlert) {
Button("Agora não", role: .cancel) {}
Button("Ativar") { Task { await enablePushNotifications() } }
} message: {
Text("Ative as notificações para acompanhar em tempo real as atualizações do seu pedido.")
}
.task {
await loadInitialOrder()
await maybePromptPushOptIn()
tracker.onOrderUpdated = { updated in
order = updated
if let inlinePhone = updated.storePhone, inlinePhone.isEmpty == false {
storeContactPhone = inlinePhone
}
isLoading = false
errorMessage = nil
Task { await maybePromptPushOptIn() }
}
tracker.start(orderId: orderId, jwt: DefaultTokenStore().jwt)
}
.onAppear {
attachReviewSavedObserverIfNeeded()
}
.onDisappear {
tracker.stop()
detachReviewSavedObserver()
}
.navigationDestination(item: $reviewDraft) { draft in
MyReviewsView(initialOrder: draft)
}
}
private func attachReviewSavedObserverIfNeeded() {
guard reviewSavedObserver == nil else { return }
reviewSavedObserver = NotificationCenter.default.addObserver(
forName: .orderReviewDidSave,
object: nil,
queue: nil
) { payload in
guard let orderIdFromEvent = payload.userInfo?["orderId"] as? String else { return }
let currentOrderId = (order?.id ?? orderId)
.trimmingCharacters(in: .whitespacesAndNewlines)
.lowercased()
if orderIdFromEvent.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == currentOrderId {
didSaveReviewForCurrentOrder = true
}
}
}
private func detachReviewSavedObserver() {
guard let reviewSavedObserver else { return }
NotificationCenter.default.removeObserver(reviewSavedObserver)
self.reviewSavedObserver = nil
}
private var screenHeader: some View {
ZStack {
Text("Pedido \(displayOrderTitle)")
.font(AppTypography.heading2)
.foregroundStyle(AppColors.textPrimary)
HStack {
Button(action: {
if let postOrderBack {
postOrderBack()
} else {
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 var topHeader: some View {
HStack(spacing: 10) {
Circle()
.fill(Color.white.opacity(0.2))
.frame(width: 28, height: 28)
.overlay(
Circle()
.fill(Color.white.opacity(0.35))
.frame(width: 14, height: 14)
)
Text("Acompanhamento em tempo real")
.font(AppTypography.heading2)
.foregroundStyle(Color.white)
Spacer()
}
.padding(.horizontal, 16)
.padding(.vertical, 18)
.background(AppColors.primary)
.clipShape(RoundedRectangle(cornerRadius: 28, style: .continuous))
}
private var orderTitleSection: some View {
VStack(alignment: .leading, spacing: 6) {
Text(order?.storeName?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false ? (order?.storeName ?? "Seu pedido") : "Seu pedido")
.font(AppTypography.heading1)
.foregroundStyle(AppColors.textPrimary)
Text("Pedido #\(displayOrderTitle)")
.font(AppTypography.body)
.foregroundStyle(AppColors.textMuted)
}
.frame(maxWidth: .infinity, alignment: .leading)
}
@ViewBuilder
private var statusBanner: some View {
if let errorMessage, errorMessage.isEmpty == false {
statusBadge(
title: errorMessage,
fg: Color.red,
bg: Color.red.opacity(0.12),
icon: "xmark.octagon.fill"
)
} else if isLoading {
HStack(spacing: 10) {
ProgressView()
Text("Atualizando status do pedido...")
.font(AppTypography.body)
.foregroundStyle(AppColors.textMuted)
}
.frame(maxWidth: .infinity, alignment: .leading)
} else if isCanceled {
VStack(alignment: .leading, spacing: 10) {
statusBadge(
title: "Pedido cancelado",
fg: Color.red,
bg: Color.red.opacity(0.12),
icon: "xmark.circle.fill"
)
if cancellationReasonText.isEmpty == false {
Button("Ver motivo do cancelamento") {
showCancellationReason = true
}
.font(AppTypography.heading3)
.foregroundStyle(Color.red)
.buttonStyle(.plain)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
} else if isWaitingPayment {
statusBadge(
title: "Aguardando pagamento",
fg: Color(hex: "#A16207"),
bg: Color(hex: "#FDE68A").opacity(0.35),
icon: "clock.fill"
)
} else {
statusBadge(
title: successBannerTitle,
fg: AppColors.primary,
bg: AppColors.brandSoft,
icon: "checkmark.circle.fill"
)
}
}
private var timelineSection: some View {
VStack(alignment: .leading, spacing: 14) {
Text("Progresso do Pedido")
.font(AppTypography.heading2)
.foregroundStyle(AppColors.textPrimary)
if let order {
VStack(alignment: .leading, spacing: 0) {
ForEach(Array(timelineSteps(for: order).enumerated()), id: \.element.id) { index, step in
timelineRow(step: step, isLast: index == timelineSteps(for: order).count - 1)
}
}
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(16)
.background(AppColors.surface)
.clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous))
}
private var placeholderCard: some View {
VStack(spacing: 12) {
HStack(spacing: 10) {
Circle()
.fill(AppColors.brandSoft)
.frame(width: 28, height: 28)
.overlay(
Image(systemName: summaryStatusIcon)
.font(.system(size: 12, weight: .bold))
.foregroundStyle(AppColors.primary)
)
Text(summaryStatusTitle)
.font(AppTypography.heading3)
.foregroundStyle(AppColors.textPrimary)
Spacer()
}
Group {
if hasTrackingImage {
Image(trackingImageName)
.renderingMode(.original)
.resizable()
.scaledToFit()
} else if hasPlaceholderProductImage {
Image("placeholder-product")
.renderingMode(.original)
.resizable()
.scaledToFit()
} else {
ZStack {
Color.black.opacity(0.08)
Image(systemName: "shippingbox.fill")
.font(.system(size: 52, weight: .bold))
.foregroundStyle(AppColors.primary)
}
}
}
.frame(height: 220)
.clipShape(RoundedRectangle(cornerRadius: 18, style: .continuous))
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(16)
.background(AppColors.surface)
.clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous))
}
private var contactButton: some View {
Button("CONTATO") {
openStoreWhatsApp()
}
.font(AppTypography.heading2)
.foregroundStyle(AppColors.textPrimary)
.frame(maxWidth: .infinity, minHeight: 56)
.background(AppColors.surface)
.clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous))
.buttonStyle(.plain)
}
private var reviewButton: some View {
Button("AVALIAR PEDIDO") {
guard let reviewTargetDraft else { return }
reviewDraft = reviewTargetDraft
}
.font(AppTypography.heading2)
.foregroundStyle(Color(hex: "#0E1A06"))
.frame(maxWidth: .infinity, minHeight: 56)
.background(Color(hex: "#7CF02A"))
.clipShape(Capsule())
.buttonStyle(.plain)
}
private func statusBadge(title: String, fg: Color, bg: Color, icon: String) -> some View {
HStack(spacing: 8) {
Image(systemName: icon)
.font(.system(size: 14, weight: .bold))
Text(title)
.font(AppTypography.heading3)
}
.foregroundStyle(fg)
.padding(.horizontal, 12)
.padding(.vertical, 8)
.background(bg)
.clipShape(Capsule())
.frame(maxWidth: .infinity, alignment: .leading)
}
private func timelineRow(step: TrackingStep, isLast: Bool) -> some View {
HStack(alignment: .top, spacing: 12) {
VStack(spacing: 0) {
Circle()
.fill(stepDotColor(step))
.frame(width: 20, height: 20)
.overlay(
Group {
if step.isCompleted {
Image(systemName: "checkmark")
.font(.system(size: 11, weight: .bold))
.foregroundStyle(.white)
} else if step.isActive {
Circle()
.fill(.white)
.frame(width: 8, height: 8)
} else {
Circle()
.stroke(Color(hex: "#C5CBD4"), lineWidth: 2)
.frame(width: 8, height: 8)
}
}
)
if isLast == false {
Rectangle()
.fill(stepLineColor(step))
.frame(width: 2, height: 36)
}
}
VStack(alignment: .leading, spacing: 2) {
Text(step.title)
.font(AppTypography.heading2)
.foregroundStyle(stepTitleColor(step))
if step.subtitle.isEmpty == false {
Text(step.subtitle)
.font(AppTypography.body)
.foregroundStyle(stepSubtitleColor(step))
}
if let time = step.time, time.isEmpty == false {
Text(time)
.font(AppTypography.caption)
.foregroundStyle(AppColors.textMuted)
}
}
Spacer()
}
}
private func stepDotColor(_ step: TrackingStep) -> Color {
if isCanceled {
return Color(hex: "#C5CBD4")
}
if isWaitingPayment && step.id == "paid" {
return Color(hex: "#F59E0B")
}
if step.isCompleted || step.isActive {
return AppColors.primary
}
return Color(hex: "#E5E7EB")
}
private func stepLineColor(_ step: TrackingStep) -> Color {
if isCanceled { return Color(hex: "#E5E7EB") }
if step.isCompleted || step.isActive {
return AppColors.primary.opacity(0.85)
}
return Color(hex: "#E5E7EB")
}
private func stepTitleColor(_ step: TrackingStep) -> Color {
if isCanceled { return Color(hex: "#9CA3AF") }
if step.isCompleted || step.isActive {
return AppColors.textPrimary
}
return Color(hex: "#9CA3AF")
}
private func stepSubtitleColor(_ step: TrackingStep) -> Color {
if isCanceled { return Color(hex: "#9CA3AF") }
if isWaitingPayment && step.id == "paid" {
return Color(hex: "#A16207")
}
if step.isCompleted || step.isActive {
return AppColors.primary
}
return Color(hex: "#9CA3AF")
}
private func timelineSteps(for order: PublicOrderResult) -> [TrackingStep] {
let isPickup = normalized(order.deliveryType ?? order.deliveryTypeLabel).contains("PICKUP")
let allSteps: [(id: String, title: String, subtitle: String, statuses: [String])] = [
("confirmed", "Pedido confirmado", "Seu pedido foi recebido", ["PENDING", "ACCEPTED", "PAYMENT_PENDING"]),
("preparing", "Pedido em produção", "Seu pedido está sendo preparado", ["PREPARING"]),
("ready", isPickup ? "Pronto para retirada" : "Pronto para entrega", isPickup ? "Retire no balcão quando avisarmos" : "Pedido pronto para sair", ["READY"]),
("delivering", "Em rota de entrega", customerOtpSubtitle, ["DELIVERING", "OUT_FOR_DELIVERY", "EM_ROTA", "ON_ROUTE"]),
("completed", isPickup ? "Retirado" : "Entregue", "Pedido finalizado", ["COMPLETED", "DELIVERED"])
]
let stepsBase = isPickup ? allSteps.filter { $0.id != "delivering" } : allSteps
let currentIndex = currentStepIndex(isPickup: isPickup)
return stepsBase.enumerated().map { index, step in
let event = timelineEvent(for: order, statuses: step.statuses)
let isCompleted = event?.completed ?? (isCanceled == false && index < currentIndex)
let isActive = event?.active ?? (isCanceled == false && index == currentIndex)
return TrackingStep(
id: step.id,
title: step.title,
subtitle: timelineSubtitle(stepId: step.id, fallback: step.subtitle, eventLabel: event?.label),
time: formatTime(event?.time),
isCompleted: isCompleted || (index == currentIndex && isCompletedTerminalStep(index: index, isPickup: isPickup)),
isActive: isActive && isCompletedTerminalStep(index: index, isPickup: isPickup) == false
)
}
}
private func currentStepIndex(isPickup: Bool) -> Int {
let normalizedStatus = normalized(order?.status)
if isCanceled {
if normalizedStatus.contains("DELIVER") || normalizedStatus.contains("ROTA") { return isPickup ? 2 : 3 }
if normalizedStatus.contains("READY") { return 2 }
if normalizedStatus.contains("PREPAR") || normalizedStatus.contains("ACCEPT") || normalizedStatus.contains("PENDING") { return 1 }
return 0
}
if isWaitingPayment {
return 0
}
if let timelineIndex = timelineProgressStepIndex(isPickup: isPickup) {
return timelineIndex
}
if normalizedStatus.contains("COMPLETED") || normalizedStatus.contains("DELIVERED") {
return isPickup ? 3 : 4
}
if isPickup {
if normalizedStatus.contains("READY") { return 2 }
if normalizedStatus.contains("PREPAR") { return 1 }
return 0
}
if normalizedStatus.contains("DELIVER") || normalizedStatus.contains("ROTA") {
return 3
}
if normalizedStatus.contains("READY") {
return 2
}
if normalizedStatus.contains("PREPAR") {
return 1
}
return 0
}
private func timelineProgressStepIndex(isPickup: Bool) -> Int? {
guard let order else { return nil }
let stepStatuses: [[String]] = isPickup
? [
["PENDING", "ACCEPTED", "PAYMENT_PENDING"],
["PREPARING"],
["READY"],
["COMPLETED", "DELIVERED"]
]
: [
["PENDING", "ACCEPTED", "PAYMENT_PENDING"],
["PREPARING"],
["READY"],
["DELIVERING", "OUT_FOR_DELIVERY", "EM_ROTA", "ON_ROUTE"],
["COMPLETED", "DELIVERED"]
]
var strongestIndex: Int? = nil
var fallbackIndex: Int? = nil
for (index, statuses) in stepStatuses.enumerated() {
let statusSet = Set(statuses.map(normalized))
let events = order.timeline.filter { event in
statusSet.contains(normalized(event.status))
}
guard events.isEmpty == false else { continue }
fallbackIndex = index
if events.contains(where: { $0.active == true || $0.completed == true }) {
strongestIndex = index
}
}
return strongestIndex ?? fallbackIndex
}
private func isCompletedTerminalStep(index: Int, isPickup: Bool) -> Bool {
let terminalIndex = isPickup ? 3 : 4
return isCanceled == false && currentStepIndex(isPickup: isPickup) >= terminalIndex && index == terminalIndex
}
private func timelineEvent(for order: PublicOrderResult, statuses: [String]) -> PublicOrderTimelineEvent? {
let statusSet = Set(statuses.map(normalized))
return order.timeline.first(where: { statusSet.contains(normalized($0.status)) })
}
private func timelineSubtitle(stepId: String, fallback: String, eventLabel: String?) -> String {
if stepId == "delivering", customerOtpCode != nil {
return customerOtpSubtitle
}
let label = (eventLabel ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
if shouldUseTimelineEventLabel(label, fallback: fallback) {
return label
}
return fallback
}
private func shouldUseTimelineEventLabel(_ label: String, fallback: String) -> Bool {
guard label.isEmpty == false else { return false }
let foldedLabel = label
.folding(options: [.diacriticInsensitive, .caseInsensitive], locale: .current)
.lowercased()
let foldedFallback = fallback
.folding(options: [.diacriticInsensitive, .caseInsensitive], locale: .current)
.lowercased()
if foldedLabel == foldedFallback { return false }
let englishHints = [
"order",
"confirmed",
"in progress",
"progress",
"delivery",
"delivered",
"ready",
"sent",
"out for",
"began"
]
if englishHints.contains(where: { foldedLabel.contains($0) }) {
return false
}
return true
}
private var customerOtpSubtitle: String {
if let otp = customerOtpCode {
return "Código para o entregador: \(otp)"
}
return "Aguardando saída para entrega"
}
private var customerOtpCode: String? {
let raw = (order?.customerOtp ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
if raw.isEmpty { return nil }
let digits = raw.filter(\.isNumber)
if digits.count == 4 {
return digits
}
return nil
}
private var displayOrderTitle: String {
if let short = order?.shortId, short.isEmpty == false { return short }
if let orderIdValue = order?.id, orderIdValue.isEmpty == false { return orderIdValue }
if let initialShortId, initialShortId.isEmpty == false { return initialShortId }
return String(orderId.prefix(6))
}
private var isCanceled: Bool {
normalized(order?.status).contains("CANCEL")
}
private var isWaitingPayment: Bool {
let paymentStatus = normalized(order?.paymentStatus)
if isOnlinePaymentMethod == false {
return false
}
if paymentStatus == "PENDING" {
return true
}
return order?.isPaymentConfirmed == false
}
private var isOnlinePaymentMethod: Bool {
let code = normalized(order?.paymentMethodCode)
if code == "PIX" || code == "CREDIT_CARD" {
return true
}
return false
}
private var successBannerTitle: String {
if isCompletedOrder {
if isPickupOrder {
return "Pedido retirado"
}
return "Pedido entregue"
}
if isOnlinePaymentMethod {
return "Pagamento confirmado"
}
return "Pedido confirmado"
}
private var summaryStatusTitle: String {
if isCanceled {
return "Seu pedido foi cancelado"
}
if isWaitingPayment {
return "Aguardando confirmação de pagamento"
}
if isCompletedOrder {
return isPickupOrder ? "Seu pedido foi retirado" : "Seu pedido foi entregue"
}
return "Seu pedido está em andamento"
}
private var summaryStatusIcon: String {
if isCanceled {
return "xmark"
}
if isWaitingPayment {
return "clock.fill"
}
return "checkmark"
}
private var isPickupOrder: Bool {
normalized(order?.deliveryType ?? order?.deliveryTypeLabel).contains("PICKUP")
}
private var isCompletedOrder: Bool {
let status = normalized(order?.status)
if status.contains("COMPLETED") || status.contains("DELIVERED") {
return true
}
let stepIndex = currentStepIndex(isPickup: isPickupOrder)
let terminalIndex = isPickupOrder ? 3 : 4
return isCanceled == false && isWaitingPayment == false && stepIndex >= terminalIndex
}
private var shouldShowReviewButton: Bool {
guard isCompletedOrder else { return false }
guard isCanceled == false else { return false }
guard let reviewTargetDraft else { return false }
if didSaveReviewForCurrentOrder { return false }
if hasPersistedReviewForCurrentOrder { return false }
return order?.review == nil
}
private var hasPersistedReviewForCurrentOrder: Bool {
reviewIdCandidates.contains { candidate in
SessionStateStore.hasOrderReview(orderId: candidate)
}
}
private var reviewIdCandidates: [String] {
let values = [
orderId,
order?.id,
order?.realId,
order?.shortId
]
var unique: [String] = []
var seen = Set<String>()
for raw in values {
let normalized = (raw ?? "")
.trimmingCharacters(in: .whitespacesAndNewlines)
.lowercased()
guard normalized.isEmpty == false, seen.contains(normalized) == false else { continue }
seen.insert(normalized)
unique.append(normalized)
}
return unique
}
private var reviewTargetDraft: ReviewDraft? {
let idFromOrder = (order?.id ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
let id = idFromOrder.isEmpty ? orderId : idFromOrder
guard id.isEmpty == false else { return nil }
return ReviewDraft(
orderId: id,
storeId: order?.storeId,
shortId: order?.shortId ?? initialShortId,
storeName: order?.storeName,
storeLogoURL: order?.storeLogoURL,
createdAt: order?.createdAt,
total: order?.total
)
}
private var cancellationReasonText: String {
let value = (order?.cancellationReason ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
return value.isEmpty ? "Sem detalhe informado." : value
}
private var hasTrackingImage: Bool {
imageResourceExists(trackingImageName)
}
private var hasPlaceholderProductImage: Bool {
imageResourceExists("placeholder-product")
}
private func imageResourceExists(_ name: String) -> Bool {
UIImage(named: name) != nil
}
private var trackingImageName: String {
let isPickup = normalized(order?.deliveryType ?? order?.deliveryTypeLabel).contains("PICKUP")
let stepIndex = currentStepIndex(isPickup: isPickup)
if isCanceled {
return "tracking-canceled"
}
if isWaitingPayment {
return "tracking-pending"
}
switch stepIndex {
case 0:
return "tracking-pending"
case 1:
return "tracking-preparing"
case 2:
return "tracking-ready"
case 3:
return "tracking-delivering"
default:
return "tracking-completed"
}
}
private func normalized(_ value: String?) -> String {
(value ?? "")
.folding(options: [.diacriticInsensitive, .caseInsensitive], locale: .current)
.uppercased()
.trimmingCharacters(in: .whitespacesAndNewlines)
}
private func formatTime(_ rawValue: String?) -> String? {
guard let rawValue else { return nil }
let value = rawValue.trimmingCharacters(in: .whitespacesAndNewlines)
if value.isEmpty { return nil }
if value.contains("T"), let isoTime = formatISOTime(value) {
return isoTime
}
if value.range(of: #"^\d{2}:\d{2}"#, options: .regularExpression) != nil {
return String(value.prefix(5))
}
return value
}
private func formatISOTime(_ value: String) -> String? {
let iso = ISO8601DateFormatter()
iso.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
var date = iso.date(from: value)
if date == nil {
iso.formatOptions = [.withInternetDateTime]
date = iso.date(from: value)
}
guard let date else { return nil }
let formatter = DateFormatter()
formatter.locale = Locale(identifier: "pt_BR")
formatter.dateFormat = "HH:mm"
return formatter.string(from: date)
}
@MainActor
private func loadInitialOrder() async {
logger.info("OrderTracking initial fetch orderId=\(orderId)")
do {
let response = try await ApiService().publicOrder(orderId: orderId)
if response.error {
errorMessage = response.message ?? "Não foi possível carregar o pedido."
logger.error("OrderTracking initial fetch API error orderId=\(orderId) message=\(response.message ?? "unknown")")
} else if let result = response.result {
order = result
storeContactPhone = result.storePhone
errorMessage = nil
logger.info("OrderTracking initial fetch success orderId=\(orderId) status=\(result.status ?? "nil") paymentStatus=\(result.paymentStatus ?? "nil")")
await refreshStoreContactPhone(for: result)
}
} catch {
errorMessage = "Não foi possível carregar o pedido."
logger.error("OrderTracking initial fetch failure orderId=\(orderId) error=\(error.localizedDescription)")
}
isLoading = false
}
/// Touchpoint 2 of docs/api/push-notifications-integration-guide.md §2b
/// last practical moment to recover an opted-out user before order-status
/// push (§6, `type: "order_status"`) goes silent for them for this order.
@MainActor
private func maybePromptPushOptIn() async {
guard showPushOptInAlert == false, isWaitingPayment == false, isCanceled == false else { return }
guard SessionStateStore.shouldPromptPushOptIn() else { return }
let osAuthorized = await PushNotificationCoordinator.shared.currentAuthorizationState() == .authorized
var serverEnabled = false
if let profileResponse = try? await ApiService().profile(), profileResponse.error == false {
serverEnabled = profileResponse.result?.notificationsEnabled ?? false
}
guard osAuthorized == false || serverEnabled == false else { return }
SessionStateStore.recordPushOptInPrompted()
showPushOptInAlert = true
}
@MainActor
private func enablePushNotifications() async {
let profile = await PushNotificationCoordinator.shared.enableNotifications()
if profile?.notificationsEnabled != true {
SnackbarCenter.shared.show(
title: "Ative notificações nos Ajustes do iPhone para acompanhar seu pedido.",
style: .warning,
icon: "bell.slash.fill",
duration: 3.5
)
}
}
@MainActor
private func refreshStoreContactPhone(for order: PublicOrderResult) async {
guard let storeId = order.storeId?.trimmingCharacters(in: .whitespacesAndNewlines), storeId.isEmpty == false else {
if let inlinePhone = order.storePhone, inlinePhone.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false {
storeContactPhone = inlinePhone
}
return
}
do {
let response = try await ApiService().storeInfo(storeId: storeId)
if response.error == false, let result = response.result {
let phone = (result.whatsapp ?? result.phone ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
if phone.isEmpty == false {
storeContactPhone = phone
return
}
}
} catch {
// Fallback handled below.
}
if let inlinePhone = order.storePhone, inlinePhone.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false {
storeContactPhone = inlinePhone
}
}
private func openStoreWhatsApp() {
guard let phoneRaw = storeContactPhone,
let url = makeWhatsAppURL(from: phoneRaw) else {
SnackbarCenter.shared.show(
title: "Telefone da loja indisponível.",
style: .warning,
icon: "exclamationmark.triangle.fill",
duration: 2.8
)
return
}
openURL(url)
}
private func makeWhatsAppURL(from phoneRaw: String) -> URL? {
var digits = phoneRaw.filter(\.isNumber)
if digits.isEmpty { return nil }
if digits.hasPrefix("0") {
digits = String(digits.drop(while: { $0 == "0" }))
}
if digits.hasPrefix("55") == false && (digits.count == 10 || digits.count == 11) {
digits = "55" + digits
}
guard digits.count >= 12 else { return nil }
return URL(string: "https://wa.me/\(digits)")
}
}

View File

@@ -0,0 +1,892 @@
import SwiftUI
struct OrdersView: View {
@Binding var appState: AppState
@Environment(\.dismiss) var dismiss
@State var isLoading = false
@State var errorMessage: String? = nil
@State var orders: [AppOrderSummary] = []
@State var hasLoadedOnce = false
@State var storeRatingByStoreId: [String: Double] = [:]
@State var storeRatingByStoreName: [String: Double] = [:]
@State var storeLogoByStoreId: [String: String] = [:]
@State var storeLogoByStoreName: [String: String] = [:]
@State var selectedOrderRoute: OrderRouteContext? = nil
var body: some View {
ScrollView(showsIndicators: false) {
VStack(alignment: .leading, spacing: 14) {
screenHeader(title: "Meus Pedidos", onBack: { dismiss() })
if isLoading {
ProgressView()
.frame(maxWidth: .infinity)
.padding(.top, 24)
} else if let errorMessage, errorMessage.isEmpty == false {
Text(errorMessage)
.font(AppTypography.body)
.foregroundStyle(Color.red)
.multilineTextAlignment(.center)
.frame(maxWidth: .infinity)
.padding(.top, 24)
} else if orders.isEmpty {
Text("Nenhum pedido encontrado.")
.font(AppTypography.body)
.foregroundStyle(AppColors.textMuted)
.padding(.top, 24)
} else {
ForEach(orders) { order in
orderCard(order)
}
}
}
.padding(.horizontal, 20)
.padding(.top, 18)
.padding(.bottom, UIDevice.bottomNotch + 18)
}
.background(AppColors.backgroundLight)
.navigationBarBackButtonHidden(true)
.appHiddenNavigationBar()
.task {
await loadOrdersIfNeeded()
await refreshStoreRatings()
}
.onAppear {
if let pending = appState.pendingOrderDeepLink {
appState.pendingOrderDeepLink = nil
selectedOrderRoute = pending
}
}
.refreshable {
// Decoupled from .refreshable's own cancellable wrapping Task
// see StoreDetailView's .refreshable for why.
await Task {
await loadOrders(force: true)
await refreshStoreRatings()
}.value
}
.navigationDestination(item: $selectedOrderRoute) { context in
OrderEntryDestinationView(
orderId: context.orderId,
initialShortId: context.shortId,
fallbackPaymentMethod: context.paymentMethod,
fallbackTotal: context.total,
routeIntent: context.intent,
appState: $appState
)
}
}
private func screenHeader(title: String, onBack: @escaping () -> Void) -> some View {
ZStack {
Text(title)
.font(AppTypography.heading2)
.foregroundStyle(AppColors.textPrimary)
HStack {
Button(action: onBack) {
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 orderCard(_ order: AppOrderSummary) -> some View {
let status = orderVisualStatus(for: order)
let detailsRoute = OrderRouteContext(
orderId: trackingOrderId(for: order),
shortId: order.shortId,
paymentMethod: order.paymentMethod,
total: order.total,
intent: .details
)
let trackingRoute = OrderRouteContext(
orderId: trackingOrderId(for: order),
shortId: order.shortId,
paymentMethod: order.paymentMethod,
total: order.total,
intent: .tracking
)
return VStack(alignment: .leading, spacing: 14) {
HStack(spacing: 12) {
AsyncStoreImage(imageURL: resolvedMediaURL(storeLogoURL(for: order)))
.frame(width: 80, height: 80)
.clipShape(RoundedRectangle(cornerRadius: 20, style: .continuous))
VStack(alignment: .leading, spacing: 5) {
HStack(spacing: 6) {
Text(order.storeName?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false ? (order.storeName ?? "Pedido") : "Pedido #\(order.shortId ?? order.id)")
.font(AppTypography.heading2)
.minimumScaleFactor(0.01)
.foregroundStyle(AppColors.textPrimary)
.lineLimit(1)
Spacer()
Text(status.badgeTitle)
.font(AppTypography.caption)
.minimumScaleFactor(0.01)
.foregroundStyle(status.badgeForeground)
.padding(.horizontal, 10)
.padding(.vertical, 6)
.background(status.badgeBackground)
.clipShape(Capsule())
}
HStack(spacing: 6) {
Text(orderMetaText(order))
.font(AppTypography.body)
.minimumScaleFactor(0.01)
.foregroundStyle(AppColors.textMuted)
.lineLimit(1)
if let rating = storeRating(for: order) {
Text("")
.font(AppTypography.body)
.minimumScaleFactor(0.01)
.foregroundStyle(AppColors.textMuted)
Image(systemName: "star.fill")
.font(.system(size: 11, weight: .bold))
.minimumScaleFactor(0.01)
.foregroundStyle(Color(hex: "#7CF02A"))
Text(String(format: "%.1f", rating).replacingOccurrences(of: ".", with: ","))
.font(AppTypography.body)
.minimumScaleFactor(0.01)
.foregroundStyle(AppColors.textMuted)
}
}
}
}
Divider()
HStack(spacing: 12) {
Button(status.isCanceled ? "Ajuda" : "Ver Detalhes") {
selectedOrderRoute = detailsRoute
}
.font(AppTypography.heading3)
.foregroundStyle(AppColors.textMuted)
.lineLimit(1)
.minimumScaleFactor(0.5)
.buttonStyle(.plain)
.appLayoutPriority(0)
Spacer(minLength: 8)
Button {
if status.isInProgress {
selectedOrderRoute = trackingRoute
return
}
SnackbarCenter.shared.show(
title: "Recompra será integrada com o catálogo em breve.",
style: .info,
icon: "cart.badge.plus",
duration: 2.0
)
} label: {
HStack(spacing: 8) {
Image(systemName: status.isInProgress ? "truck.box.fill" : "arrow.clockwise")
Text(status.isInProgress ? "Acompanhar" : "Pedir Novamente")
.font(AppTypography.heading3)
.lineLimit(1)
.minimumScaleFactor(0.5)
}
.lineLimit(1)
//.frame(minWidth: status.isInProgress ? 136 : 184)
.foregroundStyle(status.actionForeground)
.padding(.horizontal, 14)
.padding(.vertical, 11)
.background(status.actionBackground)
.clipShape(Capsule())
}
.buttonStyle(.plain)
.appLayoutPriority(2)
}
}
.padding(18)
.background(AppColors.surface)
.overlay(alignment: .leading) {
if status.isInProgress {
RoundedRectangle(cornerRadius: 3, style: .continuous)
.fill(Color(hex: "#C8F06E"))
.frame(width: 5)
.padding(.vertical, 20)
}
}
.clipShape(RoundedRectangle(cornerRadius: 28, style: .continuous))
}
private func orderVisualStatus(for order: AppOrderSummary) -> OrderRowStatusStyle {
let rawDetailed = (order.statusDetailed ?? "").trimmingCharacters(in: .whitespacesAndNewlines).uppercased()
let rawStatus = (order.status ?? "").trimmingCharacters(in: .whitespacesAndNewlines).uppercased()
let technical = [rawDetailed, rawStatus].joined(separator: "|")
if technical.contains("CANCEL") || technical.contains("REFUND") {
return .canceled
}
if technical.contains("COMPLETED") || technical.contains("DELIVERED") {
return .delivered
}
if technical.contains("IN_DELIVERY")
|| technical.contains("DELIVERING")
|| technical.contains("OUT_FOR_DELIVERY")
|| technical.contains("PENDING")
|| technical.contains("ACCEPTED")
|| technical.contains("PREPAR")
|| technical.contains("READY") {
return .inProgress
}
let label = (order.statusLabel ?? "").trimmingCharacters(in: .whitespacesAndNewlines).uppercased()
if label.contains("CANCEL") {
return .canceled
}
if label.contains("CONCLU") || label.contains("ENTREGUE") {
return .delivered
}
return .inProgress
}
private func formatCurrency(_ value: Double) -> String {
String(format: "R$ %.2f", value).replacingOccurrences(of: ".", with: ",")
}
private func orderMetaText(_ order: AppOrderSummary) -> String {
let dateText = formatOrderDate(order.createdAt) ?? "Agora"
let totalText = formatCurrency(order.total ?? 0)
return "\(dateText)\(totalText)"
}
private func formatOrderDate(_ isoValue: String?) -> String? {
guard let isoValue, isoValue.isEmpty == false else { return nil }
let iso = ISO8601DateFormatter()
iso.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
var date = iso.date(from: isoValue)
if date == nil {
iso.formatOptions = [.withInternetDateTime]
date = iso.date(from: isoValue)
}
guard let date else { return nil }
let formatter = DateFormatter()
formatter.locale = Locale(identifier: "pt_BR")
formatter.dateFormat = "dd MMM, HH:mm"
return formatter.string(from: date)
}
private func trackingOrderId(for order: AppOrderSummary) -> String {
let orderCandidate = (order.orderId ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
if orderCandidate.isEmpty == false {
return orderCandidate
}
let candidate = (order.realId ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
if candidate.isEmpty == false {
return candidate
}
return order.id
}
private func normalizedOrderId(_ value: String?) -> String {
(value ?? "").trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
}
private func normalizedStoreName(_ value: String?) -> String {
(value ?? "")
.trimmingCharacters(in: .whitespacesAndNewlines)
.folding(options: [.diacriticInsensitive, .caseInsensitive], locale: .current)
.lowercased()
}
@MainActor
private func loadOrdersIfNeeded() async {
guard hasLoadedOnce == false else { return }
await loadOrders(force: false)
}
@MainActor
private func loadOrders(force: Bool) async {
if isLoading { return }
if force == false, hasLoadedOnce { return }
isLoading = true
errorMessage = nil
let previousOrders = orders
var trackedMapped: [AppOrderSummary] = []
let cachedTracked = SessionStateStore.loadTrackedOrders()
if cachedTracked.isEmpty == false {
trackedMapped = cachedTracked.map {
AppOrderSummary.fromTracked($0)
}
if hasLoadedOnce == false, previousOrders.isEmpty {
orders = mergeOrders(apiOrders: [], trackedOrders: trackedMapped)
}
}
do {
let response = try await ApiService().listOrders(forceRefresh: force)
if response.error {
if previousOrders.isEmpty == false {
orders = previousOrders
}
errorMessage = response.message ?? "Não foi possível carregar os pedidos."
} else {
let remote = response.result ?? []
orders = mergeOrders(apiOrders: remote, trackedOrders: trackedMapped)
}
} catch {
if isCancelledRequest(error) {
if previousOrders.isEmpty == false {
orders = previousOrders
}
isLoading = false
return
}
if orders.isEmpty {
errorMessage = "Não foi possível carregar os pedidos."
}
}
isLoading = false
hasLoadedOnce = true
}
private func mergeOrders(apiOrders: [AppOrderSummary], trackedOrders: [AppOrderSummary]) -> [AppOrderSummary] {
var map: [String: AppOrderSummary] = [:]
var sourceRank: [String: Int] = [:]
for (index, item) in trackedOrders.enumerated() {
let key = identityKey(for: item)
map[key] = item
if sourceRank[key] == nil {
sourceRank[key] = 10_000 + index
}
}
for (index, item) in apiOrders.enumerated() {
let key = identityKey(for: item)
map[key] = item
sourceRank[key] = index
}
return map.values.sorted { lhs, rhs in
let leftDate = orderDateSortValue(lhs)
let rightDate = orderDateSortValue(rhs)
if leftDate != rightDate {
return leftDate > rightDate
}
let leftRank = sourceRank[identityKey(for: lhs)] ?? Int.max
let rightRank = sourceRank[identityKey(for: rhs)] ?? Int.max
if leftRank != rightRank {
return leftRank < rightRank
}
let leftNumericId = Int(lhs.id)
let rightNumericId = Int(rhs.id)
if let leftNumericId, let rightNumericId, leftNumericId != rightNumericId {
return leftNumericId > rightNumericId
}
return lhs.id.localizedCompare(rhs.id) == .orderedDescending
}
}
private func identityKey(for order: AppOrderSummary) -> String {
let raw = trackingOrderId(for: order)
return raw.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
}
private func orderDateSortValue(_ order: AppOrderSummary) -> Date {
parseDateForSort(order.updatedAt)
?? parseDateForSort(order.createdAt)
?? .distantPast
}
private func parseDateForSort(_ rawValue: String?) -> Date? {
guard let rawValue else { return nil }
let value = rawValue.trimmingCharacters(in: .whitespacesAndNewlines)
if value.isEmpty { return nil }
let iso = ISO8601DateFormatter()
iso.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
if let date = iso.date(from: value) { return date }
iso.formatOptions = [.withInternetDateTime]
if let date = iso.date(from: value) { return date }
let formatter = DateFormatter()
formatter.locale = Locale(identifier: "pt_BR")
let formats = [
"yyyy-MM-dd'T'HH:mm:ss.SSSXXXXX",
"yyyy-MM-dd'T'HH:mm:ssXXXXX",
"yyyy-MM-dd HH:mm:ss",
"yyyy-MM-dd'T'HH:mm:ss",
"yyyy-MM-dd'T'HH:mm:ss.SSSZ",
"yyyy-MM-dd HH:mm:ss Z",
"dd/MM/yyyy HH:mm:ss",
"dd/MM/yyyy HH:mm",
"dd/MM/yyyy"
]
for format in formats {
formatter.dateFormat = format
if let date = formatter.date(from: value) {
return date
}
}
return nil
}
private func isCancelledRequest(_ error: Error) -> Bool {
if error is CancellationError {
return true
}
if let networkError = error as? NetworkError,
case .transportError(let message) = networkError {
let normalized = message.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
return normalized.contains("cancel")
}
if let networkError = error as? NetworkError,
case .cancelled = networkError {
return true
}
return error.localizedDescription.lowercased().contains("cancel")
}
@MainActor
private func refreshStoreRatings() async {
var storeList: [StoreSummary] = AppContentCache.shared.value(
for: AppCacheKey.homeStoresLatestSnapshot,
as: [StoreSummary].self
) ?? []
if storeList.isEmpty {
let response = try? await ApiService().listStores()
storeList = response?.result ?? []
}
var byId: [String: Double] = [:]
var byName: [String: Double] = [:]
var logoById: [String: String] = [:]
var logoByName: [String: String] = [:]
for store in storeList {
let storeId = normalizedOrderId(store.id)
let nameKey = normalizedStoreName(store.name)
if let rating = store.rating, rating > 0 {
if storeId.isEmpty == false { byId[storeId] = rating }
if nameKey.isEmpty == false { byName[nameKey] = rating }
}
if let logo = store.logo, logo.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false {
if storeId.isEmpty == false { logoById[storeId] = logo }
if nameKey.isEmpty == false { logoByName[nameKey] = logo }
}
}
storeRatingByStoreId = byId
storeRatingByStoreName = byName
storeLogoByStoreId = logoById
storeLogoByStoreName = logoByName
}
private func storeRating(for order: AppOrderSummary) -> Double? {
let storeId = normalizedOrderId(order.storeId)
if storeId.isEmpty == false, let fromId = storeRatingByStoreId[storeId] {
return fromId
}
let nameKey = normalizedStoreName(order.storeName)
if nameKey.isEmpty == false, let fromName = storeRatingByStoreName[nameKey] {
return fromName
}
return nil
}
private func storeLogoURL(for order: AppOrderSummary) -> String? {
if let url = order.storeLogoURL, url.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false {
return url
}
let storeId = normalizedOrderId(order.storeId)
if storeId.isEmpty == false, let logo = storeLogoByStoreId[storeId] { return logo }
let nameKey = normalizedStoreName(order.storeName)
if nameKey.isEmpty == false, let logo = storeLogoByStoreName[nameKey] { return logo }
return nil
}
private func resolvedMediaURL(_ raw: String?) -> String? {
ImageSourceResolver.resolve(raw)
}
}
struct OrderRouteContext: Identifiable, Hashable {
var id: String { "\(orderId)|\(intent.rawValue)" }
let orderId: String
let shortId: String?
let paymentMethod: String?
let total: Double?
let intent: OrderRouteIntent
}
enum OrderRouteIntent: String, Hashable {
case details
case tracking
case auto
}
private enum OrderRowStatusStyle {
case delivered
case inProgress
case canceled
var badgeTitle: String {
switch self {
case .delivered: return "Entregue"
case .inProgress: return "Em andamento"
case .canceled: return "Cancelado"
}
}
var badgeForeground: Color {
switch self {
case .delivered: return Color(hex: "#16843B")
case .inProgress: return Color(hex: "#B06A28")
case .canceled: return Color(hex: "#D62828")
}
}
var badgeBackground: Color {
switch self {
case .delivered: return Color(hex: "#E8F7E9")
case .inProgress: return Color(hex: "#FFF2E5")
case .canceled: return Color(hex: "#FDECEC")
}
}
var actionForeground: Color {
switch self {
case .inProgress: return .white
case .delivered, .canceled: return Color(hex: "#0E1A06")
}
}
var actionBackground: Color {
switch self {
case .inProgress: return Color(hex: "#111216")
case .delivered, .canceled: return Color(hex: "#C8F06E")
}
}
var isInProgress: Bool { self == .inProgress }
var isCanceled: Bool { self == .canceled }
}
extension AppOrderSummary {
static func fromTracked(_ tracked: PublicOrderResult) -> AppOrderSummary {
AppOrderSummary(
id: tracked.id,
orderId: tracked.realId ?? tracked.id,
realId: tracked.realId,
storeId: nil,
shortId: tracked.shortId,
total: tracked.total,
status: tracked.status,
statusDetailed: nil,
statusLabel: nil,
nextAction: nil,
paymentStatus: tracked.paymentStatus,
paymentMethod: tracked.paymentMethod,
deliveryType: tracked.deliveryType,
storeName: tracked.storeName,
storePhone: tracked.storePhone,
storeLogoURL: tracked.storeLogoURL,
createdAt: tracked.createdAt,
updatedAt: tracked.updatedAt
)
}
init(
id: String,
orderId: String?,
realId: String?,
storeId: String?,
shortId: String?,
total: Double?,
status: String?,
statusDetailed: String?,
statusLabel: String?,
nextAction: String?,
paymentStatus: String?,
paymentMethod: String?,
deliveryType: String?,
storeName: String?,
storePhone: String?,
storeLogoURL: String?,
createdAt: String?,
updatedAt: String?
) {
self.id = id
self.orderId = orderId
self.realId = realId
self.storeId = storeId
self.shortId = shortId
self.total = total
self.status = status
self.statusDetailed = statusDetailed
self.statusLabel = statusLabel
self.nextAction = nextAction
self.paymentStatus = paymentStatus
self.paymentMethod = paymentMethod
self.deliveryType = deliveryType
self.storeName = storeName
self.storePhone = storePhone
self.storeLogoURL = storeLogoURL
self.createdAt = createdAt
self.updatedAt = updatedAt
}
}
struct OrderEntryDestinationView: View {
let orderId: String
let initialShortId: String?
let fallbackPaymentMethod: String?
let fallbackTotal: Double?
let routeIntent: OrderRouteIntent
@Binding var appState: AppState
@State var isResolvingRoute = true
@State var didResolve = false
@State var pixContext: PixPaymentContext? = nil
@State var orderTrackingContext: OrderTrackingContext? = nil
@State var orderDetails: PublicOrderResult? = nil
var body: some View {
Group {
if isResolvingRoute {
VStack(spacing: 10) {
ProgressView()
Text("Carregando pedido...")
.font(AppTypography.body)
.foregroundStyle(AppColors.textMuted)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(AppColors.backgroundLight)
} else if let pixContext {
PaymentPixView(
context: pixContext,
appState: $appState,
onPaymentConfirmed: {
orderTrackingContext = OrderTrackingContext(orderId: pixContext.orderId, shortId: pixContext.shortId)
},
onOpenTracking: {
orderTrackingContext = OrderTrackingContext(orderId: pixContext.orderId, shortId: pixContext.shortId)
}
)
} else if let orderDetails {
OrderDetailsView(order: orderDetails, orderId: orderId, initialShortId: initialShortId, appState: $appState)
} else {
OrderTrackingView(orderId: orderId, initialShortId: initialShortId)
}
}
.navigationDestination(item: $orderTrackingContext) { context in
OrderTrackingView(orderId: context.orderId, initialShortId: context.shortId)
}
.task {
guard didResolve == false else { return }
didResolve = true
await resolveRoute()
}
}
@MainActor
func resolveRoute() async {
defer { isResolvingRoute = false }
let order = await fetchOrderForRouting()
guard let order else { return }
if routeIntent == .details {
orderDetails = order
return
}
// For both .tracking and .auto: show payment screen if payment is still pending.
// Timeline only shows once payment is confirmed or method is off-app.
if shouldOpenPaymentScreen(for: order) {
let normalizedMethod = normalizePaymentMethod(order)
if normalizedMethod.contains("PIX") {
let pixFromPayment = order.payment?.pix
let pixFromPayload = order.paymentPayload
let copyPaste = (pixFromPayment?.copyPaste?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false)
? pixFromPayment?.copyPaste
: pixFromPayload?.copyPaste
let qrCodeImage = (pixFromPayment?.qrCodeImage?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false)
? pixFromPayment?.qrCodeImage
: pixFromPayload?.qrCodeImage
let expirationDate = (pixFromPayment?.expirationDate?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false)
? pixFromPayment?.expirationDate
: pixFromPayload?.expirationDate
let storeId = order.storeId ?? ""
pixContext = PixPaymentContext(
id: order.id,
orderId: order.id,
shortId: order.shortId ?? initialShortId,
storeId: storeId,
copyPaste: copyPaste?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false
? (copyPaste ?? "")
: "Código PIX indisponível no momento. Aguarde e tente novamente.",
qrCodeImageBase64: qrCodeImage,
expirationDate: expirationDate,
total: order.total ?? 0,
profileName: "",
profileEmail: "",
profilePhone: "",
addressZip: nil,
addressNumber: nil,
deliveryType: order.deliveryType ?? "DELIVERY",
itemsJSON: "[]"
)
return
}
if normalizedMethod == "CREDIT_CARD" || normalizedMethod == "DEBIT_CARD" {
orderTrackingContext = OrderTrackingContext(orderId: order.id, shortId: order.shortId ?? initialShortId)
return
}
}
// For .auto only: route terminal/canceled orders to details instead of timeline.
if routeIntent == .auto && shouldOpenOrderDetails(for: order) {
orderDetails = order
}
// .tracking (and .auto fallthrough) nil states body renders OrderTrackingView
}
@MainActor
func fetchOrderForRouting() async -> PublicOrderResult? {
logger.info("OrderEntry fetch route orderId=\(orderId)")
do {
let response = try await ApiService().publicOrder(orderId: orderId)
if response.error == false, let result = response.result {
logger.info("OrderEntry fetch route success orderId=\(orderId) status=\(result.status ?? "nil") paymentStatus=\(result.paymentStatus ?? "nil")")
return result
}
logger.error("OrderEntry fetch route API error orderId=\(orderId) message=\(response.message ?? "unknown")")
} catch {
logger.error("OrderEntry fetch route failure orderId=\(orderId) error=\(error.localizedDescription)")
}
return nil
}
func isOnlinePaymentMethod(_ order: PublicOrderResult) -> Bool {
let method = normalizePaymentMethod(order)
return method == "PIX" || method == "CREDIT_CARD" || method == "DEBIT_CARD"
}
func shouldOpenOrderDetails(for order: PublicOrderResult) -> Bool {
let status = normalize(order.status)
if status.contains("CANCEL") { return true }
if status.contains("COMPLETED") || status.contains("DELIVERED") { return true }
return false
}
func shouldOpenPaymentScreen(for order: PublicOrderResult) -> Bool {
guard order.isPaymentConfirmed == false else { return false }
guard isOnlinePaymentMethod(order) else { return false }
guard isPaymentPending(order) else { return false }
guard isInStorePayment(order) == false else { return false }
let status = normalize(order.status)
if status.contains("PREPAR") ||
status.contains("READY") ||
status.contains("DELIVER") ||
status.contains("ROTA") ||
status.contains("COMPLETED") ||
status.contains("CANCEL") ||
status.contains("REFUND") {
return false
}
let paymentStatus = normalize(order.paymentStatus)
if paymentStatus.contains("CONFIRM") ||
paymentStatus.contains("PAID") ||
paymentStatus.contains("RECEIV") ||
paymentStatus.contains("APPROV") {
return false
}
let method = normalizePaymentMethod(order)
if method == "PIX" {
return hasPixPayload(order)
}
return method == "CREDIT_CARD" || method == "DEBIT_CARD"
}
func hasPixPayload(_ order: PublicOrderResult) -> Bool {
let fromPayment = (order.payment?.pix?.copyPaste ?? "")
.trimmingCharacters(in: .whitespacesAndNewlines)
if fromPayment.isEmpty == false { return true }
let fromPayload = (order.paymentPayload?.copyPaste ?? "")
.trimmingCharacters(in: .whitespacesAndNewlines)
return fromPayload.isEmpty == false
}
func isPaymentPending(_ order: PublicOrderResult) -> Bool {
let status = normalize(order.status)
let paymentStatus = normalize(order.paymentStatus)
let nextAction = normalize(order.nextAction)
if status.contains("PAYMENT_PENDING") {
return true
}
if paymentStatus.contains("PENDING") {
return true
}
if nextAction.contains("PAY") || nextAction.contains("PAYMENT") {
return true
}
return false
}
func isInStorePayment(_ order: PublicOrderResult) -> Bool {
let nextAction = normalize(order.nextAction)
if nextAction.contains("TRACK") || nextAction.contains("DELIVER") {
return true
}
let status = normalize(order.status)
if status.contains("PREPAR") ||
status.contains("READY") ||
status.contains("DELIVER") ||
status.contains("ROTA") ||
status.contains("OUT_FOR_DELIVERY") {
return true
}
return false
}
func normalizePaymentMethod(_ order: PublicOrderResult) -> String {
let first = normalize(order.paymentMethodCode)
if first.isEmpty == false {
return first
}
let second = normalize(order.paymentMethod)
if second.isEmpty == false {
return second
}
return normalize(fallbackPaymentMethod)
}
func normalize(_ value: String?) -> String {
(value ?? "")
.trimmingCharacters(in: .whitespacesAndNewlines)
.uppercased()
}
}

View File

@@ -0,0 +1,122 @@
import Foundation
import SwiftUI
struct PizzaFlavorAddonsSheet: View {
let flavor: StoreCatalogProduct
@Binding var quantities: [String: Int]
@Environment(\.dismiss) var dismiss
var body: some View {
ScrollView(showsIndicators: false) {
VStack(alignment: .leading, spacing: 12) {
screenHeader
Text(flavor.name)
.font(AppTypography.heading2)
.foregroundStyle(AppColors.textPrimary)
if flavor.addonGroups.isEmpty {
Text("Este sabor não possui adicionais.")
.font(AppTypography.body)
.foregroundStyle(AppColors.textMuted)
} else {
ForEach(flavor.addonGroups) { group in
VStack(alignment: .leading, spacing: 10) {
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.textPrimary)
Text("+ \(formatCurrency(item.price ?? 0))")
.font(.caption)
.foregroundStyle(AppColors.textMuted)
}
Spacer()
HStack(spacing: 8) {
Button(action: { decrement(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((quantities[item.id] ?? 0) <= 0)
Text("\(quantities[item.id] ?? 0)")
.font(AppTypography.heading3)
.foregroundStyle(AppColors.textPrimary)
.frame(minWidth: 18)
Button(action: { increment(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)
}
}
}
}
.padding(12)
.background(AppColors.surface)
.clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous))
}
}
}
.padding(20)
}
.background(AppColors.backgroundLight.ignoresSafeArea())
.appHiddenNavigationBar()
.navigationBarBackButtonHidden(true)
}
private var screenHeader: some View {
ZStack {
Text("Adicionais")
.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 increment(_ addonId: String) {
quantities[addonId, default: 0] += 1
}
private func decrement(_ addonId: String) {
let current = quantities[addonId] ?? 0
if current <= 1 {
quantities.removeValue(forKey: addonId)
} else {
quantities[addonId] = current - 1
}
}
private func formatCurrency(_ value: Double) -> String {
String(format: "R$ %.2f", value).replacingOccurrences(of: ".", with: ",")
}
}

View 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: ",")
}
}

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

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
}
}
}

View File

@@ -0,0 +1,429 @@
import SwiftUI
#if canImport(LCEssentials)
import LCEssentials
#endif
import UIKit
struct ProfileView: View {
@Binding var selectedTab: MainTab
let tokenStore: TokenStore
@Binding var appState: AppState
let enterAuth: () -> Void
@State var openAddressesOnboarding = false
@State var onboardingMessage: String? = nil
@State var showLogoutAlert = false
@State var showDeleteAccountAlert = false
@State var isDeletingAccount = false
@State private var openOrders = false
let tabBarClearance: CGFloat = 120
var body: some View {
ScrollView(showsIndicators: false) {
VStack(spacing: 18) {
NavigationLink {
UserProfileView(appState: $appState)
} label: {
header
}
.buttonStyle(.plain)
VStack(spacing: 14) {
NavigationLink {
OrdersView(appState: $appState)
} label: {
ProfileMenuRow(icon: "list.bullet.clipboard.fill", title: "Meus Pedidos")
}
.buttonStyle(.plain)
NavigationLink {
AddressesView(message: nil, appState: $appState)
} label: {
ProfileMenuRow(icon: "mappin.circle.fill", title: "Meus Endereços")
}
.buttonStyle(.plain)
NavigationLink {
SavedCardsView(appState: $appState)
} label: {
ProfileMenuRow(icon: "creditcard.fill", title: "Meus Cartões")
}
.buttonStyle(.plain)
NavigationLink {
MyReviewsView()
} label: {
ProfileMenuRow(icon: "star.fill", title: "Minhas Avaliações")
}
.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")
// } label: {
// ProfileMenuRow(icon: "gearshape.fill", title: "Configurações")
// }
// .buttonStyle(.plain)
}
.padding(.horizontal, 20)
Button(action: { showLogoutAlert = true }) {
HStack(spacing: 10) {
Image(systemName: "rectangle.portrait.and.arrow.right")
.font(.system(size: 18, weight: .semibold))
Text("Sair da Conta")
.font(AppTypography.heading3)
}
.foregroundStyle(Color.red)
}
.buttonStyle(.plain)
.padding(.top, 10)
.padding(.horizontal, 20)
Button(action: { showDeleteAccountAlert = true }) {
HStack(spacing: 10) {
if isDeletingAccount {
ProgressView()
.tint(Color.red)
} else {
Image(systemName: "trash.fill")
.font(.system(size: 16, weight: .semibold))
}
Text("Excluir Conta")
.font(AppTypography.body)
}
.foregroundStyle(Color.red.opacity(0.7))
}
.buttonStyle(.plain)
.disabled(isDeletingAccount)
.padding(.top, 2)
.padding(.horizontal, 20)
Text("Versão 1.0b")
.font(.system(size: 16, weight: .medium))
.foregroundStyle(AppColors.textMuted)
HStack(spacing: 6) {
NavigationLink {
TermsOfUseView()
} label: {
Text("Termos de Uso")
}
.buttonStyle(.plain)
Text("·")
NavigationLink {
PrivacyPolicyView()
} label: {
Text("Política de Privacidade")
}
.buttonStyle(.plain)
}
.font(.system(size: 14, weight: .medium))
.foregroundStyle(AppColors.textMuted)
.padding(.bottom, 12)
}
}
.ignoresSafeArea(edges: .top)
.appBottomSafeAreaInset {
Rectangle()
.fill(AppColors.backgroundLight.opacity(0.8))
.frame(height: tabBarClearance)
.padding(.bottom, -UIDevice.bottomNotch)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(AppColors.backgroundLight)
.alert("Sair da conta?", isPresented: $showLogoutAlert) {
Button("Cancelar", role: .cancel) {}
Button("Sair", role: .destructive) {
logout()
}
} message: {
Text("Tem certeza que deseja sair da sua conta?")
}
.alert("Excluir sua conta?", isPresented: $showDeleteAccountAlert) {
Button("Cancelar", role: .cancel) {}
Button("Excluir", role: .destructive) {
Task { await deleteAccount() }
}
} message: {
Text("Essa ação é permanente. Seus dados de perfil, endereços, cartões e favoritos serão excluídos e não poderão ser recuperados.")
}
.onAppear {
guard let message = appState.address.onboardingMessage else {
return
}
onboardingMessage = message
appState.address.onboardingMessage = nil
openAddressesOnboarding = true
}
.sheet(isPresented: $openAddressesOnboarding) {
NavigationStack {
AddressesView(message: onboardingMessage, appState: $appState)
}
}
.navigationDestination(isPresented: $openOrders) {
OrdersView(appState: $appState)
}
.onChange(of: appState.shouldNavigateToOrders) { _, val in
if val {
appState.shouldNavigateToOrders = false
openOrders = true
}
}
.onChange(of: appState.pendingOrderDeepLink) { _, val in
if val != nil {
openOrders = true
}
}
}
private var header: some View {
VStack(spacing: 10) {
ZStack(alignment: .bottomTrailing) {
Circle()
.fill(Color.white.opacity(0.18))
.frame(width: 96, height: 96)
.overlay(
Group {
if let picture = profilePictureURL {
AsyncStoreImage(imageURL: picture)
.frame(width: 92, height: 92)
.clipShape(Circle())
} else {
Text(profileInitials)
.font(.system(size: 30, weight: .bold))
.foregroundStyle(AppColors.textInverse)
}
}
)
Circle()
.fill(AppColors.tertiary)
.frame(width: 36, height: 36)
.overlay(
Image(systemName: "pencil")
.font(.system(size: 15, weight: .bold))
.foregroundStyle(AppColors.textPrimary)
)
.overlay(
Circle()
.stroke(Color.black.opacity(0.15), lineWidth: 1)
)
}
Text(profileName)
.font(.system(size: 22, weight: .heavy))
.foregroundStyle(AppColors.textInverse)
.lineLimit(1)
.minimumScaleFactor(0.8)
HStack(spacing: 8) {
Text("Ver Perfil")
.font(.system(size: 16, weight: .medium))
Image(systemName: "arrow.right")
.font(.system(size: 14, weight: .semibold))
}
.foregroundStyle(AppColors.tertiary)
}
.frame(maxWidth: .infinity)
.padding(.top, 54)
.padding(.bottom, 32)
.background(headerGradient)
.clipShape(
ProfileHeaderShape(
topLeadingRadius: 0,
bottomLeadingRadius: 42,
bottomTrailingRadius: 42,
topTrailingRadius: 0
)
)
.ignoresSafeArea(edges: .top)
}
private var headerGradient: LinearGradient {
LinearGradient(
colors: [Color(hex: "#123221"), Color(hex: "#0F2A1C")],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
}
private var profileName: String {
let trimmed = appState.profile.name.trimmingCharacters(in: .whitespacesAndNewlines)
return trimmed.isEmpty ? "Alex Silva" : trimmed
}
private var profileInitials: String {
let parts = profileName.split(separator: " ").prefix(2)
let joined = parts.compactMap { $0.first }.map(String.init).joined()
return joined.isEmpty ? "AS" : joined.uppercased()
}
private var profilePictureURL: String? {
let raw = appState.profile.profilePicture.trimmingCharacters(in: .whitespacesAndNewlines)
guard raw.isEmpty == false else { return nil }
return ImageSourceResolver.resolve(raw)
}
private func logout() {
tokenStore.clear()
SessionStateStore.clearActiveUser()
SessionStateStore.clearTrackedOrders()
AppContentCache.shared.invalidate()
AppImageCache.shared.invalidateAll()
appState = AppState()
enterAuth()
}
@MainActor
private func deleteAccount() async {
guard isDeletingAccount == false else { return }
isDeletingAccount = true
defer { isDeletingAccount = false }
do {
let response = try await ApiService().deleteAccount()
guard response.error == false else {
SnackbarCenter.shared.show(
title: response.message ?? "Não foi possível excluir sua conta.",
style: .error,
icon: "xmark.octagon.fill",
duration: 3.5
)
return
}
logout()
} catch {
SnackbarCenter.shared.show(
title: "Não foi possível excluir sua conta. Tente novamente.",
style: .error,
icon: "xmark.octagon.fill",
duration: 3.5
)
}
}
}
struct ProfileLoggedOutView: View {
let enterAuth: () -> Void
var body: some View {
VStack(spacing: 18) {
Spacer()
Image(systemName: "person.crop.circle.badge.questionmark")
.font(.system(size: 56, weight: .regular))
.foregroundStyle(AppColors.textMuted)
Text("Entre na sua conta")
.font(AppTypography.heading2)
.foregroundStyle(AppColors.textPrimary)
Text("Faça login ou cadastre-se para ver seu perfil, pedidos e endereços.")
.font(AppTypography.body)
.foregroundStyle(AppColors.textMuted)
.multilineTextAlignment(.center)
.padding(.horizontal, 32)
Button {
enterAuth()
} label: {
Text("Entrar ou Cadastrar")
.font(AppTypography.heading3)
.foregroundStyle(AppColors.textInverse)
.frame(maxWidth: .infinity)
.padding(.vertical, 14)
.background(AppColors.primary)
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
}
.buttonStyle(.plain)
.padding(.horizontal, 32)
.padding(.top, 8)
Spacer()
Spacer()
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(AppColors.backgroundLight)
}
}
struct ProfileMenuRow: View {
let icon: String
let title: String
var badge: String? = nil
var body: some View {
HStack(spacing: 12) {
Circle()
.fill(Color(hex: "#E9F0E2"))
.frame(width: 44, height: 44)
.overlay(
Image(systemName: icon)
.font(.system(size: 18, weight: .semibold))
.foregroundStyle(Color(hex: "#173824"))
)
Text(title)
.font(.system(size: 16, weight: .semibold))
.foregroundStyle(Color(hex: "#0F1A34"))
Spacer(minLength: 10)
if let badge {
Text(badge)
.font(.system(size: 12, weight: .bold))
.foregroundStyle(Color(hex: "#1C2A1C"))
.padding(.horizontal, 10)
.padding(.vertical, 6)
.background(Color(hex: "#EAF1D6"))
.clipShape(Capsule())
}
Image(systemName: "chevron.right")
.font(.system(size: 15, weight: .semibold))
.foregroundStyle(Color(hex: "#BFC7D4"))
}
.padding(.horizontal, 18)
.padding(.vertical, 14)
.background(AppColors.surface)
.clipShape(RoundedRectangle(cornerRadius: 20, style: .continuous))
.shadow(color: Color.black.opacity(0.02), radius: 6, y: 2)
}
}
struct ProfileHeaderShape: Shape {
var topLeadingRadius: CGFloat
var bottomLeadingRadius: CGFloat
var bottomTrailingRadius: CGFloat
var topTrailingRadius: CGFloat
func path(in rect: CGRect) -> Path {
let tl = min(min(topLeadingRadius, rect.width / 2), rect.height / 2)
let tr = min(min(topTrailingRadius, rect.width / 2), rect.height / 2)
let bl = min(min(bottomLeadingRadius, rect.width / 2), rect.height / 2)
let br = min(min(bottomTrailingRadius, rect.width / 2), rect.height / 2)
var path = Path()
path.move(to: CGPoint(x: rect.minX + tl, y: rect.minY))
path.addLine(to: CGPoint(x: rect.maxX - tr, y: rect.minY))
path.addArc(center: CGPoint(x: rect.maxX - tr, y: rect.minY + tr), radius: tr, startAngle: .degrees(-90), endAngle: .degrees(0), clockwise: false)
path.addLine(to: CGPoint(x: rect.maxX, y: rect.maxY - br))
path.addArc(center: CGPoint(x: rect.maxX - br, y: rect.maxY - br), radius: br, startAngle: .degrees(0), endAngle: .degrees(90), clockwise: false)
path.addLine(to: CGPoint(x: rect.minX + bl, y: rect.maxY))
path.addArc(center: CGPoint(x: rect.minX + bl, y: rect.maxY - bl), radius: bl, startAngle: .degrees(90), endAngle: .degrees(180), clockwise: false)
path.addLine(to: CGPoint(x: rect.minX, y: rect.minY + tl))
path.addArc(center: CGPoint(x: rect.minX + tl, y: rect.minY + tl), radius: tl, startAngle: .degrees(180), endAngle: .degrees(270), clockwise: false)
path.closeSubpath()
return path
}
}

View File

@@ -0,0 +1,174 @@
import SwiftUI
/// State -> city picker for anonymous browsing (public store locator).
/// Replaces AddressesView in the address-picker modal when the user is
/// not authenticated see docs/plans/public-store-locator-sdd.md.
struct PublicLocationPickerView: View {
@Binding var appState: AppState
@Environment(\.dismiss) var dismiss
private enum Step {
case state
case city
}
@State private var step: Step = .state
@State private var locations: PublicLocationsResult = [:]
@State private var selectedState: String? = nil
@State private var isLoading = false
@State private var errorMessage: String? = nil
private var states: [String] {
locations.keys.sorted()
}
private var cities: [String] {
guard let selectedState else { return [] }
return (locations[selectedState] ?? []).sorted()
}
var body: some View {
ZStack {
AppColors.backgroundLight.ignoresSafeArea()
VStack(spacing: 20) {
header
if isLoading {
ProgressView()
.padding(.top, 40)
} else if let errorMessage {
VStack(spacing: 12) {
Text(errorMessage)
.font(AppTypography.body)
.foregroundStyle(AppColors.textMuted)
.multilineTextAlignment(.center)
Button("Tentar novamente") {
Task { await loadLocations() }
}
.font(AppTypography.heading3)
.foregroundStyle(AppColors.primary)
}
.padding(.horizontal, 20)
.padding(.top, 40)
} else {
list
}
Spacer(minLength: 0)
}
.padding(.top, 18)
}
.navigationBarBackButtonHidden(true)
.appHiddenNavigationBar()
.task {
await loadLocations()
}
}
private var header: some View {
ZStack {
Text(step == .state ? "Escolha seu estado" : "Escolha sua cidade")
.font(AppTypography.heading2)
.foregroundStyle(AppColors.textPrimary)
HStack {
Button(action: back) {
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()
}
}
.padding(.horizontal, 20)
}
private var list: some View {
ScrollView(showsIndicators: false) {
LazyVStack(spacing: 12) {
switch step {
case .state:
ForEach(states, id: \.self) { state in
rowButton(title: state) {
selectedState = state
step = .city
}
}
case .city:
ForEach(cities, id: \.self) { city in
rowButton(title: city) {
confirmSelection(city: city)
}
}
}
}
.padding(.horizontal, 20)
.padding(.top, 8)
}
}
private func rowButton(title: String, action: @escaping () -> Void) -> some View {
Button(action: action) {
HStack {
Text(title)
.font(AppTypography.body)
.foregroundStyle(AppColors.textPrimary)
Spacer()
Image(systemName: "chevron.right")
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(AppColors.textMuted)
}
.padding(.horizontal, 18)
.padding(.vertical, 16)
.background(AppColors.surface)
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
}
.buttonStyle(.plain)
}
private func back() {
switch step {
case .city:
step = .state
errorMessage = nil
case .state:
dismiss()
}
}
private func confirmSelection(city: String) {
guard let selectedState else { return }
GuestLocationStore.shared.selectedState = selectedState
GuestLocationStore.shared.selectedCity = city
appState.address.display = "\(city), \(selectedState)"
appState.address.onboardingMessage = nil
dismiss()
}
@MainActor
private func loadLocations() async {
isLoading = true
errorMessage = nil
do {
locations = try await PublicLocationService.shared.fetchLocations()
if locations.isEmpty {
errorMessage = "Nenhum estado disponível no momento."
}
} catch {
errorMessage = "Não foi possível carregar. Tente novamente."
}
isLoading = false
}
}
#Preview {
NavigationStack {
PublicLocationPickerView(appState: .constant(AppState()))
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,244 @@
import SwiftUI
struct SavedCardsView: View {
@Binding var appState: AppState
@Environment(\.dismiss) var dismiss
@State private var cards: [SavedCard] = []
@State private var isLoading = false
@State private var errorMessage: String? = nil
@State private var openSwipeRowId: String? = nil
@State private var deletingCardId: String? = nil
@State private var showAddCard = false
private var canDelete: Bool { cards.count > 1 }
private let tabBarClearance: CGFloat = 96
var body: some View {
ZStack {
AppColors.backgroundLight.ignoresSafeArea()
ScrollView(showsIndicators: false) {
VStack(spacing: 20) {
screenHeader
VStack(spacing: 12) {
if isLoading {
ProgressView()
.frame(maxWidth: .infinity)
.padding(.top, 32)
} else if let errorMessage {
Text(errorMessage)
.font(AppTypography.body)
.foregroundStyle(AppColors.textMuted)
.multilineTextAlignment(.center)
.padding(.top, 32)
} else if cards.isEmpty {
Text("Nenhum cartão cadastrado")
.font(AppTypography.body)
.foregroundStyle(AppColors.textMuted)
.padding(.top, 32)
} else {
ForEach(cards) { card in
if canDelete {
SwipeToDeleteAddressRow(
rowId: card.id,
openRowId: $openSwipeRowId,
isDeleting: deletingCardId == card.id,
onDelete: { deleteCard(card) }
) {
cardRow(card)
.appContentShape(Rectangle())
.onTapGesture {
if openSwipeRowId == card.id { openSwipeRowId = nil }
}
}
.id(card.id)
.opacity(deletingCardId == card.id ? 0.6 : 1.0)
.disabled(deletingCardId != nil)
} else {
cardRow(card)
}
}
}
}
}
.padding(.horizontal, 20)
.padding(.top, 18)
.padding(.bottom, tabBarClearance + 20)
}
VStack {
Spacer()
addCardButton
.padding(.bottom, tabBarClearance)
}
}
.navigationBarBackButtonHidden(true)
.appHiddenNavigationBar()
.sheet(isPresented: $showAddCard) {
NavigationStack {
AddCardFormView(appState: appState, isFirstCard: cards.isEmpty) { newCard in
cards.append(newCard)
}
}
}
.task { await loadCards() }
}
private var screenHeader: some View {
ZStack {
Text("Meus Cartões")
.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 var addCardButton: some View {
ZStack(alignment: .bottom) {
Rectangle()
.fill(AppColors.backgroundLight)
.frame(height: 136)
Button(action: { showAddCard = true }) {
HStack(spacing: 12) {
Image(systemName: "creditcard.fill")
.font(.system(size: 20))
Text("Adicionar novo cartão")
.font(AppTypography.heading3)
}
.foregroundStyle(AppColors.textInverse)
.frame(maxWidth: .infinity)
.padding(.vertical, 18)
.background(AppColors.primary)
.clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusXL, style: .continuous))
}
.buttonStyle(.plain)
.padding(.horizontal, 20)
.padding(.bottom, 14)
}
}
private func cardRow(_ card: SavedCard) -> some View {
HStack(spacing: 14) {
ZStack {
RoundedRectangle(cornerRadius: 10, style: .continuous)
.fill(AppColors.backgroundLight)
.frame(width: 52, height: 52)
if let logo = brandLogoName(for: card.brand) {
Image(logo)
.resizable()
.scaledToFit()
.frame(width: 36, height: 24)
} else {
Image(systemName: "creditcard.fill")
.font(.system(size: 22))
.foregroundStyle(AppColors.textMuted)
}
}
VStack(alignment: .leading, spacing: 3) {
HStack(spacing: 6) {
Text(card.displayLabel)
.font(AppTypography.heading3)
.foregroundStyle(AppColors.textPrimary)
if card.isDefault {
Text("Principal")
.font(AppTypography.caption)
.foregroundStyle(AppColors.primary)
.padding(.horizontal, 8)
.padding(.vertical, 3)
.background(AppColors.brandSoft)
.clipShape(Capsule())
}
}
Text("Vence \(card.expiryLabel)")
.font(AppTypography.caption)
.foregroundStyle(AppColors.textMuted)
if !canDelete {
Text("Ao menos um cartão deve permanecer")
.font(AppTypography.caption)
.foregroundStyle(AppColors.textMuted.opacity(0.7))
}
}
Spacer(minLength: 0)
}
.padding(14)
.background(AppColors.surface)
.clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous))
}
private func brandLogoName(for brand: String?) -> String? {
switch brand?.lowercased()
.trimmingCharacters(in: .whitespacesAndNewlines)
.folding(options: .diacriticInsensitive, locale: .current) {
case "visa": return "visacard_logo"
case "mastercard", "master": return "mastercard_logo"
case "amex", "american express", "americanexpress": return "amexcard_logo"
case "hipercard": return "hipercard_logo"
case "alelo": return "alelocard_logo"
case "sodexo": return "sodexo_logo"
default: return nil
}
}
@MainActor
private func loadCards() async {
guard isLoading == false else { return }
isLoading = true
errorMessage = nil
defer { isLoading = false }
do {
let response = try await ApiService().listCards()
if response.error == false {
cards = response.result ?? []
} else {
errorMessage = response.message ?? "Erro ao carregar cartões."
}
} catch {
errorMessage = "Não foi possível carregar seus cartões."
}
}
private func deleteCard(_ card: SavedCard) {
guard canDelete, deletingCardId == nil else { return }
deletingCardId = card.id
openSwipeRowId = nil
Task {
do {
let response = try await ApiService().deleteCard(cardId: card.id)
if response.error == false {
cards.removeAll { $0.id == card.id }
} else {
SnackbarCenter.shared.show(
title: response.message ?? "Não foi possível excluir o cartão.",
style: .error,
icon: "xmark.octagon.fill",
duration: 3.0
)
}
} catch {
SnackbarCenter.shared.show(
title: "Erro ao excluir cartão.",
style: .error,
icon: "xmark.octagon.fill",
duration: 3.0
)
}
deletingCardId = nil
}
}
}

View File

@@ -0,0 +1,32 @@
import Foundation
struct StoreCatalogListItem: Identifiable {
let id: String
let product: StoreCatalogProduct
let title: String
let description: String?
let imageURL: String?
let isPizzaSummary: Bool
let pizzaCategoryId: String?
let pizzaProductIds: [String]
init(
id: String,
product: StoreCatalogProduct,
title: String,
description: String?,
imageURL: String?,
isPizzaSummary: Bool = false,
pizzaCategoryId: String? = nil,
pizzaProductIds: [String] = []
) {
self.id = id
self.product = product
self.title = title
self.description = description
self.imageURL = imageURL
self.isPizzaSummary = isPizzaSummary
self.pizzaCategoryId = pizzaCategoryId
self.pizzaProductIds = pizzaProductIds
}
}

View File

@@ -0,0 +1,76 @@
import SwiftUI
struct CategoryHeaderOffsetPreferenceKey: PreferenceKey {
static let defaultValue: [String: CGFloat] = [:]
static func reduce(value: inout [String: CGFloat], nextValue: () -> [String: CGFloat]) {
value.merge(nextValue(), uniquingKeysWith: { _, new in new })
}
}
enum StoreDetailScrollCoordinateSpace {
static let name = "store-detail-scroll"
}
struct ScrollOffsetPreferenceKey: PreferenceKey {
static let defaultValue: CGFloat = 0
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
value = nextValue()
}
}
struct ScrollOffsetReader: View {
@Binding var offsetY: CGFloat
@State private var baseline: CGFloat? = nil
var body: some View {
Color.clear
.frame(height: 0)
.background(
GeometryReader { geometry in
Color.clear.preference(
key: ScrollOffsetPreferenceKey.self,
value: geometry.frame(in: .named(StoreDetailScrollCoordinateSpace.name)).minY
)
}
)
.onPreferenceChange(ScrollOffsetPreferenceKey.self) { minY in
if baseline == nil { baseline = minY }
let offset = (baseline ?? 0) - minY
if abs(offsetY - offset) > 0.5 {
offsetY = offset
}
}
}
}
struct AsyncStoreImage: View {
let imageURL: String?
var fallbackImageName: String = "placeholder-product"
var fitMode: ImageFitMode = .fill
var body: some View {
GeometryReader { geometry in
CachedRemoteImage(imageURL: imageURL, fitMode: fitMode) {
fallback
}
// Lock the scaledToFill image to the actually proposed box.
// Without this, a wide/landscape source image's fill-scaled
// ideal width can exceed the box and balloon the parent
// ZStack's ideal width, pushing sibling content off-screen.
.frame(width: geometry.size.width, height: geometry.size.height)
.clipped()
}
.background(AppColors.brandSoft)
}
private var fallback: some View {
Image(fallbackImageName)
.resizable()
.scaledToFill()
}
}

View File

@@ -0,0 +1,363 @@
import SwiftUI
extension StoreDetailView {
var topSection: some View {
ZStack(alignment: .top) {
heroSection
.frame(height: topSectionHeight)
// Hard cut: cover cannot appear below this line.
Rectangle()
.fill(AppColors.backgroundLight)
.frame(height: max(0, topSectionHeight - coverVisibleUntilY))
.offset(y: coverVisibleUntilY)
summaryCard
.padding(.horizontal, 16)
.padding(.top, cardTopInset)
storeLogoBadge
.padding(.top, cardTopInset - (storeLogoSize / 2))
}
.frame(height: topSectionHeight)
}
var heroSection: some View {
ZStack(alignment: .top) {
AsyncStoreImage(imageURL: resolvedURL(storeCoverURL), fitMode: .heightFit)
.frame(height: topSectionHeight + stretchAmount)
.offset(y: -stretchAmount)
.ignoresSafeArea(.container, edges: .top)
LinearGradient(
colors: [Color.black.opacity(0.32), Color.black.opacity(0.05)],
startPoint: .top,
endPoint: .bottom
)
.frame(height: topSectionHeight)
.ignoresSafeArea(.container, edges: .top)
.allowsHitTesting(false)
VStack(spacing: 0) {
HStack {
heroIconButton(icon: "chevron.left") {
dismiss()
}
Spacer()
heroIconButton(icon: "magnifyingglass") {}
heroIconButton(
icon: isFavoriteStore ? "heart.fill" : "heart",
foregroundStyle: isFavoriteStore ? Color.red : Color.white
) {
Task {
await toggleFavoriteStore()
}
}
}
.padding(.horizontal, 14)
.padding(.top, UIDevice.topNotch)
Spacer()
Text("RESTAURANT")
.font(AppTypography.overline)
.tracking(1.8)
.foregroundStyle(Color.white.opacity(0.92))
.padding(.bottom, 14)
}
}
}
var storeLogoBadge: some View {
ZStack {
Circle()
.fill(AppColors.surface)
.frame(width: storeLogoSize, height: storeLogoSize)
.overlay(
Circle()
.stroke(Color.white, lineWidth: 0.1)
)
AsyncStoreImage(imageURL: resolvedURL(storeLogoURL))
.frame(width: storeLogoSize - 10, height: storeLogoSize - 10)
.clipShape(Circle())
}
.shadow(color: Color.black.opacity(0.10), radius: 8, y: 3)
}
var summaryCard: some View {
VStack(spacing: 0) {
VStack(alignment: .leading, spacing: 14) {
HStack(alignment: .top) {
VStack(alignment: .leading, spacing: 6) {
Text(storeName)
.font(AppTypography.heading2)
.foregroundStyle(AppColors.textPrimary)
.lineLimit(2)
.padding(.top, 30)
Text(storeSubtitle)
.font(.caption)
.foregroundStyle(AppColors.textMuted)
.lineLimit(1)
}
Spacer()
ratingChip
}
HStack(spacing: 0) {
statItem(title: "DISTÂNCIA", value: distanceValueLabel)
Divider().frame(height: 34)
if let deliveryTime = info?.deliveryTime {
statItem(title: "TEMPO MIN.", value: deliveryTime+" min.")
} else {
statItem(title: "TEMPO", value: "--")
}
Divider().frame(height: 34)
statItem(title: "PED. MIN.", value: deliveryValueLabel)
}
.padding(.vertical, 4)
}
.padding(16)
if isStoreOpen == false {
Text(closedStoreBannerText)
.font(AppTypography.heading3)
.foregroundStyle(Color.white)
.frame(maxWidth: .infinity, minHeight: closedBannerHeight)
.background(AppColors.brandDark)
}
}
.frame(height: summaryCardHeight)
.background(AppColors.surface)
.clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusXL, style: .continuous))
.shadow(color: AppShadow.soft.color, radius: AppShadow.soft.radius, y: AppShadow.soft.y)
}
@ViewBuilder
var sectionedProducts: some View {
// Once we have categories loaded, keep showing them regardless of a
// subsequent refresh's isLoading/errorMessage state a failed or
// in-flight pull-to-refresh must never hide already-loaded content.
if categories.isEmpty == false {
sectionedProductsList
} else if isLoading {
VStack(spacing: 10) {
ProgressView()
Text("Carregando cardápio...")
.font(AppTypography.body)
.foregroundStyle(AppColors.textMuted)
}
.frame(maxWidth: .infinity, minHeight: 280, alignment: .center)
} else if let errorMessage {
VStack(alignment: .leading, spacing: 10) {
Text(errorMessage)
.font(AppTypography.body)
.foregroundStyle(AppColors.textMuted)
Button("Tentar novamente") {
Task { await loadStoreData() }
}
.font(AppTypography.heading3)
.foregroundStyle(AppColors.primary)
}
.padding(.top, 16)
} else {
Text("Cardápio indisponível no momento.")
.font(AppTypography.body)
.foregroundStyle(AppColors.textMuted)
.padding(.top, 16)
.padding(.horizontal, 16)
.padding(.bottom, 120)
}
}
@ViewBuilder
private var sectionedProductsList: some View {
ForEach(categories, id: \.id) { category in
Text(category.name)
.font(AppTypography.heading2)
.foregroundStyle(AppColors.textPrimary)
.id(sectionAnchorId(for: category.id))
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 16)
.padding(.top, 8)
.padding(.bottom, 8)
.background(AppColors.backgroundLight)
.background(
GeometryReader { geometry in
Color.clear.preference(
key: CategoryHeaderOffsetPreferenceKey.self,
value: [category.id: geometry.frame(in: .global).minY]
)
}
)
VStack(spacing: 12) {
ForEach(listItems(for: category)) { item in
productCard(item, in: category)
}
}
.padding(.horizontal, 16)
.padding(.bottom, 8)
}
Color.clear.frame(height: 140)
}
func categoryTabs(proxy: ScrollViewProxy, isPinned: Bool = false) -> some View {
let safeTop: CGFloat = {
guard isPinned else { return 0 }
return UIDevice.appSafeAreaTop
}()
return ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 10) {
ForEach(categories, id: \.id) { category in
let active = selectedCategoryId == category.id
Button {
selectedCategoryId = category.id
isProgrammaticCategoryScroll = true
withAnimation(.easeInOut(duration: 0.25)) {
proxy.scrollTo(sectionAnchorId(for: category.id), anchor: .top)
}
DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) {
isProgrammaticCategoryScroll = false
}
} label: {
Text(category.name)
.font(AppTypography.heading3)
.foregroundStyle(active ? AppColors.textInverse : AppColors.textMuted)
.padding(.horizontal, 16)
.padding(.vertical, 9)
.background(active ? AppColors.primary : AppColors.surface)
.clipShape(Capsule())
}
.buttonStyle(.plain)
}
}
.padding(.horizontal, 16)
.padding(.vertical, 10)
}
.padding(.top, safeTop)
.background(AppColors.backgroundLight)
.animation(.easeInOut(duration: 0.15), value: isPinned)
}
func productCard(_ item: StoreCatalogListItem, in category: StoreCatalogCategory) -> some View {
let product = item.product
let hasSelectableAddons = product.addonGroups.contains { $0.items.isEmpty == false }
return HStack(spacing: 12) {
VStack(alignment: .leading, spacing: 8) {
Text(item.title)
.font(AppTypography.heading3)
.foregroundStyle(AppColors.textPrimary)
.lineLimit(2)
.multilineTextAlignment(.leading)
if let description = item.description, description.isEmpty == false {
Text(description)
.font(.caption)
.foregroundStyle(AppColors.textMuted)
.lineLimit(2)
.multilineTextAlignment(.leading)
}
Text(listPriceLabel(for: product, in: category))
.font(AppTypography.heading3)
.foregroundStyle(AppColors.textPrimary)
}
Spacer()
ZStack(alignment: .bottomTrailing) {
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 {
SnackbarCenter.shared.show(title: "Loja fechada no momento.", style: .warning, icon: "moon.zzz.fill", duration: 2.0)
return
}
if item.isPizzaSummary, let pizzaCategoryId = item.pizzaCategoryId {
requestOpenPizzaSheet(categoryId: pizzaCategoryId)
return
}
if hasSelectableAddons == false {
let basePrice = product.price ?? 0
let item = CartItemState(
id: "\(storeId)::\(product.id)::base",
productId: product.id,
storeId: storeId,
name: product.name,
imageURL: resolvedURL(product.image),
quantity: 1,
unitPrice: basePrice
)
requestAddToCart(item)
} else {
requestOpenProductSheet(product)
}
} label: {
ZStack(alignment: .leading) {
Image(systemName: "plus")
.font(.system(size: 16, weight: .bold))
.foregroundStyle(AppColors.textPrimary)
.frame(width: 30, height: 30)
.background(AppColors.tertiary)
.clipShape(Circle())
let qty = quantityInCart(for: item)
if qty > 0 {
Text("\(qty)")
.font(.system(size: 10, weight: .bold))
.foregroundStyle(Color.white)
.padding(.horizontal, 5)
.padding(.vertical, 2)
.background(Color.red)
.clipShape(Capsule())
.offset(x: -6, y: -10)
.zIndex(1)
}
}
.offset(x: 3, y: -3)
.frame(width: 30, height: 30)
.appContentShape(Circle())
}
.buttonStyle(.plain)
.frame(width: 30, height: 30)
.disabled(isStoreOpen == false)
.opacity(isStoreOpen ? 1 : 0.65)
.offset(x: 7, y: 7)
}
}
.padding(12)
.background(AppColors.surface)
.clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous))
.appContentShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous))
.onTapGesture {
guard isStoreOpen else {
SnackbarCenter.shared.show(title: "Loja fechada no momento.", style: .warning, icon: "moon.zzz.fill", duration: 2.0)
return
}
if item.isPizzaSummary, let pizzaCategoryId = item.pizzaCategoryId {
requestOpenPizzaSheet(categoryId: pizzaCategoryId)
return
}
guard hasSelectableAddons else { return }
requestOpenProductSheet(product)
}
}
}

View File

@@ -0,0 +1,483 @@
import SwiftUI
extension StoreDetailView {
func heroIconButton(
icon: String,
foregroundStyle: Color = .white,
action: @escaping () -> Void
) -> some View {
Button(action: action) {
Image(systemName: icon)
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(foregroundStyle)
.frame(width: 32, height: 32)
.background(Color.white.opacity(0.24))
.clipShape(Circle())
}
.buttonStyle(.plain)
}
func statItem(title: String, value: String) -> some View {
VStack(spacing: 4) {
Text(title)
.font(AppTypography.caption)
.foregroundStyle(AppColors.textMuted)
Text(value)
.font(AppTypography.heading3)
.foregroundStyle(AppColors.textPrimary)
}
.frame(maxWidth: .infinity)
}
var ratingChip: some View {
HStack(spacing: 6) {
Image(systemName: "star.fill")
.font(.caption)
.foregroundStyle(Color(hex: "#F5B335"))
Text(String(format: "%.1f", storeRating ?? 0))
.font(AppTypography.caption)
.foregroundStyle(AppColors.textPrimary)
}
.padding(.horizontal, 10)
.padding(.vertical, 6)
.background(AppColors.brandSoft)
.clipShape(Capsule())
}
var storeSubtitle: String {
let category = (storeCategory ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
if category.isEmpty { return "Restaurant" }
return category
}
var deliveryValueLabel: String {
if let minOrder = info?.minOrder {
return formatCurrency(minOrder)
}
return "R$ --"
}
var distanceValueLabel: String {
let raw = (storeDistance ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
if raw.isEmpty {
return "--"
}
return raw
}
var isStoreOpen: Bool {
info?.isOpen ?? true
}
var isFavoriteStore: Bool {
appState.favorites.storeIds.contains(storeId)
}
@MainActor
func toggleFavoriteStore() async {
guard isFavoriteRequestInFlight == false else { return }
guard appState.session.isAuthenticated else {
SnackbarCenter.shared.show(title: "Faça login para favoritar lojas.", style: .warning, icon: "person.crop.circle.badge.exclamationmark", duration: 2.5)
return
}
let isFavorite = isFavoriteStore
isFavoriteRequestInFlight = true
defer { isFavoriteRequestInFlight = false }
do {
let response = try await ApiService().setStoreFavorite(storeId: storeId, isFavorite: isFavorite == false)
guard response.error == false, let result = response.result else {
let message = response.message ?? "Não foi possível atualizar seus favoritos."
SnackbarCenter.shared.show(title: message, style: .error, icon: "xmark.octagon.fill", duration: 3.5)
return
}
appState.favorites.storeIds = Set(result.favorites)
let successTitle = isFavorite
? "\(storeName) removida dos favoritos."
: "\(storeName) adicionada aos favoritos."
let successIcon = isFavorite ? "heart.slash.fill" : "heart.fill"
SnackbarCenter.shared.show(title: successTitle, style: .success, icon: successIcon, duration: 2.2)
} catch {
let message: String
if let networkError = error as? NetworkError {
message = networkError.errorDescription ?? "Não foi possível atualizar seus favoritos."
} else if let serviceError = error as? ApiServiceError {
message = serviceError.errorDescription ?? "Não foi possível atualizar seus favoritos."
} else {
message = "Não foi possível atualizar seus favoritos."
}
SnackbarCenter.shared.show(title: message, style: .error, icon: "xmark.octagon.fill", duration: 3.5)
}
}
var summaryCardHeight: CGFloat {
summaryCardBaseHeight + (isStoreOpen ? 0 : closedBannerHeight)
}
var closedStoreBannerText: String {
let label = info?.statusLabel?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if label.isEmpty {
return "Loja fechada • Consulte o horário de abertura"
}
let normalized = label.lowercased()
if normalized.hasPrefix("fechado") {
let cleaned = label.replacingOccurrences(of: "Fechado", with: "")
.replacingOccurrences(of: "fechado", with: "")
.trimmingCharacters(in: CharacterSet(charactersIn: " -:•"))
if cleaned.isEmpty == false {
return "Loja fechada • \(cleaned)"
}
}
return "Loja fechada • \(label)"
}
@MainActor
func loadStoreData(forceRefresh: Bool = false) async {
// A refresh (pull-to-refresh) that fails must never wipe content the
// user is already looking at only a first load with nothing yet
// loaded is allowed to show a blocking error state.
let hadExistingContent = categories.isEmpty == false
isLoading = true
if hadExistingContent == false {
errorMessage = nil
}
let infoCacheKey = "store-info:\(storeId)"
let catalogCacheKey = "store-catalog:\(storeId)"
if forceRefresh == false,
let cachedInfo: StoreInfoResult = AppContentCache.shared.value(for: infoCacheKey, as: StoreInfoResult.self),
let cachedCatalog: [StoreCatalogCategory] = AppContentCache.shared.value(for: catalogCacheKey, as: [StoreCatalogCategory].self) {
let normalizedCatalog = StoreCatalogNormalizer.sanitize(categories: cachedCatalog, storeId: storeId)
info = cachedInfo
categories = normalizedCatalog
selectedCategoryId = StoreCatalogNormalizer.preferredCategoryId(
from: normalizedCatalog,
preferredId: selectedCategoryId
)
isLoading = false
return
}
if forceRefresh {
AppContentCache.shared.invalidate(prefix: "store-info:\(storeId)")
AppContentCache.shared.invalidate(prefix: "store-catalog:\(storeId)")
}
do {
if appState.session.isAuthenticated {
let apiService = ApiService()
let infoResponse = try await apiService.storeInfo(storeId: storeId)
let catalogResponse = try await apiService.storeCatalog(storeId: storeId)
if infoResponse.error {
isLoading = false
reportStoreLoadFailure(infoResponse.message ?? "Não foi possível carregar a loja.", hadExistingContent: hadExistingContent)
return
}
if catalogResponse.error {
isLoading = false
reportStoreLoadFailure(catalogResponse.message ?? "Não foi possível carregar o catálogo.", hadExistingContent: hadExistingContent)
return
}
let normalizedCatalog = StoreCatalogNormalizer.sanitize(
categories: catalogResponse.result ?? [],
storeId: storeId
)
info = infoResponse.result
categories = normalizedCatalog
selectedCategoryId = StoreCatalogNormalizer.preferredCategoryId(
from: normalizedCatalog,
preferredId: selectedCategoryId
)
if let info = infoResponse.result {
AppContentCache.shared.set(info, for: infoCacheKey, ttl: AppCacheTTL.twoHours)
}
AppContentCache.shared.set(normalizedCatalog, for: catalogCacheKey, ttl: AppCacheTTL.twoHours)
} else {
// Anonymous browsing public/no-login store detail + catalog
// via pedifoods.com.br, mapped onto the same StoreInfoResult /
// StoreCatalogCategory models the authenticated path uses
// above, so the rest of this view doesn't need to know which
// source the data came from.
async let publicDetail = PublicLocationService.shared.fetchStoreDetail(identifier: storeId)
async let publicProducts = PublicLocationService.shared.fetchStoreProducts(storeId: storeId)
let (detail, products) = try await (publicDetail, publicProducts)
let normalizedCatalog = StoreCatalogNormalizer.sanitize(categories: products, storeId: storeId)
let publicInfo = StoreInfoResult(publicDetail: detail)
info = publicInfo
categories = normalizedCatalog
selectedCategoryId = StoreCatalogNormalizer.preferredCategoryId(
from: normalizedCatalog,
preferredId: selectedCategoryId
)
AppContentCache.shared.set(publicInfo, for: infoCacheKey, ttl: AppCacheTTL.twoHours)
AppContentCache.shared.set(normalizedCatalog, for: catalogCacheKey, ttl: AppCacheTTL.twoHours)
}
errorMessage = nil
isLoading = false
} catch {
isLoading = false
// A cancelled request (e.g. the .refreshable task torn down by a
// re-render, or superseded by a newer pull) is not a failure
// it never got a response either way, so there is nothing to
// report and no content to touch.
if isCancelledRequest(error) {
return
}
let message: String
if let network = error as? NetworkError {
message = network.errorDescription ?? "Erro ao carregar loja."
} else if let service = error as? ApiServiceError {
message = service.errorDescription ?? "Erro ao carregar loja."
} else {
message = "Erro ao carregar loja."
}
reportStoreLoadFailure(message, hadExistingContent: hadExistingContent)
}
}
private func isCancelledRequest(_ error: Error) -> Bool {
if error is CancellationError {
return true
}
if let networkError = error as? NetworkError {
switch networkError {
case .cancelled:
return true
case .transportError(let message):
let normalized = message.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
return normalized.contains("cancel")
default:
break
}
}
return error.localizedDescription.lowercased().contains("cancel")
}
@MainActor
private func reportStoreLoadFailure(_ message: String, hadExistingContent: Bool) {
if hadExistingContent {
SnackbarCenter.shared.show(title: message, style: .warning, icon: "exclamationmark.triangle.fill", duration: 3.0)
} else {
errorMessage = message
}
}
func resolvedURL(_ raw: String?) -> String? {
ImageSourceResolver.resolve(raw)
}
func formatCurrency(_ value: Double?) -> String {
guard let value else { return "R$ --" }
return String(format: "R$ %.2f", value).replacingOccurrences(of: ".", with: ",")
}
func listPriceLabel(for product: StoreCatalogProduct, in category: StoreCatalogCategory) -> String {
guard product.type?.lowercased() == "pizza", product.pizzaPrices.isEmpty == false else {
return formatCurrency(product.price)
}
if let firstSizeId = category.pizzaConfig?.sizes.first?.id,
let firstSizePrice = product.pizzaPrices[firstSizeId] {
return "A partir de \(formatCurrency(firstSizePrice))"
}
if let fallback = product.pizzaPrices.sorted(by: { $0.key < $1.key }).first?.value {
return "A partir de \(formatCurrency(fallback))"
}
return formatCurrency(product.price)
}
var topSectionHeight: CGFloat {
cardTopInset + summaryCardHeight
}
func sectionAnchorId(for categoryId: String) -> String {
"category-section-\(categoryId)"
}
func syncCategoryWithScroll() {
guard isLoading == false else { return }
guard isProgrammaticCategoryScroll == false else { return }
guard categoryHeaderOffsets.isEmpty == false else { return }
// Section whose header is nearest to the top content area wins.
let topThreshold: CGFloat = 180
let sorted = categoryHeaderOffsets.sorted { $0.value < $1.value }
if let current = sorted.last(where: { $0.value <= topThreshold })?.key {
selectedCategoryId = current
return
}
if let firstVisible = sorted.first?.key {
selectedCategoryId = firstVisible
}
}
func quantityInCart(for productId: String) -> Int {
appState.cart.items
.filter { $0.storeId == storeId && $0.productId == productId }
.reduce(0) { $0 + $1.quantity }
}
func quantityInCart(for item: StoreCatalogListItem) -> Int {
if item.isPizzaSummary {
let ids = Set(item.pizzaProductIds)
return appState.cart.items
.filter { $0.storeId == storeId && ids.contains($0.productId) }
.reduce(0) { $0 + $1.quantity }
}
return quantityInCart(for: item.product.id)
}
func listItems(for category: StoreCatalogCategory) -> [StoreCatalogListItem] {
if category.isPizzaCategory {
guard let first = category.products.first else { return [] }
let representativeImage = category.products
.compactMap(\.image)
.first { $0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false }
return [
StoreCatalogListItem(
id: "\(category.id)::pizza-summary",
product: first,
title: "Escolha seu sabor",
description: "Escolha o tamanho da sua fome",
imageURL: representativeImage ?? first.image,
isPizzaSummary: true,
pizzaCategoryId: category.id,
pizzaProductIds: category.products.map(\.id)
)
]
}
return category.products.map { product in
StoreCatalogListItem(
id: product.id,
product: product,
title: product.name,
description: product.description,
imageURL: product.image
)
}
}
func requestAddToCart(_ item: CartItemState) {
guard isStoreOpen else {
SnackbarCenter.shared.show(title: "Loja fechada no momento.", style: .warning, icon: "moon.zzz.fill", duration: 2.0)
return
}
pendingCartAction = .add
if shouldAskForStoreSwitch(for: storeId) {
pendingCartItem = item
pendingProductSheet = nil
showSwitchStoreAlert = true
return
}
applyAddToCart(item)
}
func requestSetCartItem(_ item: CartItemState) {
guard isStoreOpen || item.quantity <= 0 else {
SnackbarCenter.shared.show(title: "Loja fechada no momento.", style: .warning, icon: "moon.zzz.fill", duration: 2.0)
return
}
pendingCartAction = .set
if shouldAskForStoreSwitch(for: storeId) && item.quantity > 0 {
pendingCartItem = item
pendingProductSheet = nil
showSwitchStoreAlert = true
return
}
applySetCartItem(item)
}
func requestOpenProductSheet(_ product: StoreCatalogProduct) {
guard isStoreOpen else {
SnackbarCenter.shared.show(title: "Loja fechada no momento.", style: .warning, icon: "moon.zzz.fill", duration: 2.0)
return
}
pendingCartAction = .openProductSheet
if shouldAskForStoreSwitch(for: storeId) {
pendingCartItem = nil
pendingProductSheet = product
showSwitchStoreAlert = true
return
}
selectedProduct = product
}
func requestOpenPizzaSheet(categoryId: String) {
guard isStoreOpen else {
SnackbarCenter.shared.show(title: "Loja fechada no momento.", style: .warning, icon: "moon.zzz.fill", duration: 2.0)
return
}
pendingCartAction = .openPizzaSheet
if shouldAskForStoreSwitch(for: storeId) {
pendingCartItem = nil
pendingProductSheet = nil
pendingPizzaCategoryId = categoryId
showSwitchStoreAlert = true
return
}
selectedPizzaCategoryId = categoryId
}
func applyAddToCart(_ item: CartItemState) {
if appState.cart.storeId == nil {
appState.cart.storeId = storeId
appState.cart.storeName = storeName
}
appState.cart.add(item: item)
SnackbarCenter.shared.show(title: "Produto adicionado ao carrinho.", style: .success, icon: "checkmark.seal.fill", duration: 2.0)
}
func applySetCartItem(_ item: CartItemState) {
if item.quantity > 0, appState.cart.storeId == nil {
appState.cart.storeId = storeId
appState.cart.storeName = storeName
}
appState.cart.set(item: item)
if item.quantity > 0 {
SnackbarCenter.shared.show(title: "Carrinho atualizado.", style: .success, icon: "checkmark.seal.fill", duration: 1.8)
} else {
SnackbarCenter.shared.show(title: "Produto removido do carrinho.", style: .success, icon: "checkmark.seal.fill", duration: 1.8)
}
}
func currentQuantity(forCartItemId itemId: String) -> Int {
appState.cart.items.first(where: { $0.id == itemId })?.quantity ?? 0
}
func shouldAskForStoreSwitch(for targetStoreId: String) -> Bool {
guard appState.cart.items.isEmpty == false else { return false }
guard let currentStoreId = currentCartStoreId(),
currentStoreId.isEmpty == false else { return false }
return currentStoreId != targetStoreId
}
func currentCartStoreId() -> String? {
if let storeId = appState.cart.storeId, storeId.isEmpty == false {
return storeId
}
return appState.cart.items.first?.storeId
}
}
enum CartAction {
case add
case set
case openProductSheet
case openPizzaSheet
}

View File

@@ -0,0 +1,199 @@
import SwiftUI
#if canImport(LCEssentials)
import LCEssentials
#endif
import UIKit
struct StoreDetailView: View {
let storeId: String
let storeName: String
let storeCoverURL: String?
let storeLogoURL: String?
let storeCategory: String?
let storeRating: Double?
let storeDistance: String?
let storeDeliveryFee: Double?
@Binding var appState: AppState
@Environment(\.dismiss) var dismiss
@State var isLoading = true
@State var errorMessage: String? = nil
@State var info: StoreInfoResult? = nil
@State var categories: [StoreCatalogCategory] = []
@State var selectedCategoryId: String? = nil
@State var selectedProduct: StoreCatalogProduct? = nil
@State var selectedPizzaCategoryId: String? = nil
@State var showSwitchStoreAlert = false
@State var pendingCartItem: CartItemState? = nil
@State var pendingProductSheet: StoreCatalogProduct? = nil
@State var pendingPizzaCategoryId: String? = nil
@State var pendingCartAction: CartAction = .add
@State var didLoad = false
@State var categoryHeaderOffsets: [String: CGFloat] = [:]
@State var isProgrammaticCategoryScroll = false
@State var isFavoriteRequestInFlight = false
@State var scrollOffset: CGFloat = 0
var isCategoryTabsPinned: Bool { scrollOffset >= topSectionHeight }
var stretchAmount: CGFloat { max(0, -scrollOffset) }
let cardTopInset: CGFloat = 180
let summaryCardBaseHeight: CGFloat = 212
let closedBannerHeight: CGFloat = 44
let coverVisibleUntilY: CGFloat = 253
let storeLogoSize: CGFloat = 84
var safeAreaTop: CGFloat {
UIDevice.appSafeAreaTop
}
var body: some View {
ScrollViewReader { proxy in
ZStack(alignment: .top) {
ScrollView(showsIndicators: false) {
ScrollOffsetReader(offsetY: $scrollOffset)
LazyVStack(spacing: 0) {
topSection
// Guaranteed-visible refresh feedback, right below
// the hero not relying on the native spinner's
// position (unreliable here, see .refreshable note
// below).
if isLoading && categories.isEmpty == false {
HStack(spacing: 8) {
ProgressView()
Text("Atualizando...")
.font(AppTypography.caption)
.foregroundStyle(AppColors.textMuted)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 10)
}
categoryTabs(proxy: proxy, isPinned: false)
.opacity(isCategoryTabsPinned ? 0 : 1)
sectionedProducts
}
}
.coordinateSpace(name: StoreDetailScrollCoordinateSpace.name)
.refreshable {
// Run the actual load in its own unstructured Task and
// await that, instead of awaiting loadStoreData directly
// in this closure. SwiftUI can cancel .refreshable's own
// wrapping Task (e.g. the gesture not fully "committing")
// independent of whether the network call is still
// legitimately in flight. Awaiting Task.value here
// blocks until the detached load genuinely finishes
// (success, error, or our own 20s ApiClient timeout),
// so a premature refreshable-cancellation can no longer
// silently swallow a real in-flight request.
await Task { await loadStoreData(forceRefresh: true) }.value
}
// NOT .ignoresSafeArea here: combined with .refreshable on
// the same view, it breaks the native pull-to-refresh
// spinner's positioning (renders invisible/off-place) even
// though the gesture still fires the closure. The hero
// image and gradient above already bleed under the status
// bar independently via their own .ignoresSafeArea calls.
.background(AppColors.backgroundLight)
categoryTabs(proxy: proxy, isPinned: true)
.opacity(isCategoryTabsPinned ? 1 : 0)
.allowsHitTesting(isCategoryTabsPinned)
.zIndex(10)
}
.ignoresSafeArea(edges: .top)
.saturation(isStoreOpen ? 1 : 0)
}
.navigationBarBackButtonHidden(true)
.appHiddenNavigationBar()
.task {
guard didLoad == false else { return }
didLoad = true
await loadStoreData(forceRefresh: false)
}
.sheet(item: $selectedProduct) { product in
NavigationStack {
ProductDetailSheet(
product: product,
imageURL: resolvedURL(product.image),
storeId: storeId,
currentQuantityForItemId: { itemId in
currentQuantity(forCartItemId: itemId)
},
onAdd: { item in
guard isStoreOpen else {
SnackbarCenter.shared.show(title: "Loja fechada no momento.", style: .warning, icon: "moon.zzz.fill", duration: 2.0)
return
}
requestSetCartItem(item)
}
)
}
}
.sheet(
isPresented: Binding(
get: { selectedPizzaCategoryId != nil },
set: { isPresented in
if isPresented == false {
selectedPizzaCategoryId = nil
}
}
)
) {
if let category = categories.first(where: { $0.id == selectedPizzaCategoryId }) {
NavigationStack {
PizzaProductDetailSheet(
category: category,
storeId: storeId,
resolveImageURL: { raw in resolvedURL(raw) },
currentQuantityForItemId: { itemId in
currentQuantity(forCartItemId: itemId)
},
onAdd: { item in
guard isStoreOpen else {
SnackbarCenter.shared.show(title: "Loja fechada no momento.", style: .warning, icon: "moon.zzz.fill", duration: 2.0)
return
}
requestSetCartItem(item)
}
)
}
} else {
ProgressView()
}
}
.alert("Trocar de loja?", isPresented: $showSwitchStoreAlert) {
Button("Cancelar", role: .cancel) {
pendingCartItem = nil
pendingProductSheet = nil
pendingPizzaCategoryId = nil
}
Button("Limpar carrinho e adicionar", role: .destructive) {
appState.cart.clear()
switch pendingCartAction {
case .add:
guard let pendingCartItem else { return }
applyAddToCart(pendingCartItem)
case .set:
guard let pendingCartItem else { return }
applySetCartItem(pendingCartItem)
case .openProductSheet:
guard let pendingProductSheet else { return }
selectedProduct = pendingProductSheet
case .openPizzaSheet:
guard let pendingPizzaCategoryId else { return }
selectedPizzaCategoryId = pendingPizzaCategoryId
}
self.pendingCartItem = nil
self.pendingProductSheet = nil
self.pendingPizzaCategoryId = nil
}
} message: {
Text("Seu carrinho tem itens de outra loja. Deseja limpar o carrinho atual para adicionar este produto?")
}
.onPreferenceChange(CategoryHeaderOffsetPreferenceKey.self) { offsets in
categoryHeaderOffsets = offsets
syncCategoryWithScroll()
}
}
}

View File

@@ -0,0 +1,507 @@
import SwiftUI
#if os(iOS)
import PhotosUI
import UIKit
#endif
struct UserProfileView: View {
@Binding var appState: AppState
@Environment(\.dismiss) var dismiss
@Environment(\.openURL) private var openURL
@State var name: String = ""
@State var email: String = ""
@State var phone: String = ""
@State var cpf: String = ""
@State var profilePicture: String = ""
@State var isSaving = false
@State private var notificationsEnabled = false
@State private var isTogglingNotifications = false
@State private var showNotificationsDeniedAlert = false
@State private var faceIdEnabled = false
@State private var isTogglingFaceId = false
#if os(iOS)
@State private var selectedPhotoItem: PhotosPickerItem?
#endif
var body: some View {
ScrollView(showsIndicators: false) {
VStack(spacing: 22) {
screenHeader
avatarSection
formSection
preferencesSection
saveButton
}
.padding(.horizontal, 20)
.padding(.top, 18)
.padding(.bottom, UIDevice.bottomNotch + 24)
}
.background(AppColors.backgroundLight)
.appHiddenNavigationBar()
.navigationBarBackButtonHidden(true)
.alert("Notificações desativadas", isPresented: $showNotificationsDeniedAlert) {
Button("Agora não", role: .cancel) {}
Button("Abrir Ajustes") { openSystemSettings() }
} message: {
Text("Para receber notificações sobre seus pedidos, ative a permissão nos Ajustes do iPhone.")
}
.onAppear {
hydrateFromAppState()
}
#if os(iOS)
.onChange(of: selectedPhotoItem) { _, newItem in
Task { await applySelectedPhoto(newItem) }
}
#endif
}
private var screenHeader: some View {
ZStack {
Text("Meu Perfil")
.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 var avatarSection: some View {
VStack(spacing: 12) {
Circle()
.fill(AppColors.brandSoft)
.frame(width: 110, height: 110)
.overlay {
if let imageSource = resolvedProfilePicture {
AsyncStoreImage(imageURL: imageSource)
.frame(width: 104, height: 104)
.clipShape(Circle())
} else {
Text(initials)
.font(.system(size: 32, weight: .bold))
.foregroundStyle(AppColors.primary)
}
}
HStack(spacing: 10) {
#if os(iOS)
PhotosPicker(selection: $selectedPhotoItem, matching: .images, photoLibrary: .shared()) {
Text("Trocar Foto")
.font(AppTypography.caption)
.foregroundStyle(AppColors.primary)
.padding(.horizontal, 12)
.padding(.vertical, 8)
.background(AppColors.surface)
.clipShape(Capsule())
}
#endif
Button("Remover") {
profilePicture = ""
}
.font(AppTypography.caption)
.foregroundStyle(AppColors.textMuted)
.buttonStyle(.plain)
.disabled(resolvedProfilePicture == nil)
}
}
.frame(maxWidth: .infinity)
}
private var formSection: some View {
VStack(alignment: .leading, spacing: 14) {
textFieldSection(title: "Nome", placeholder: "Seu nome completo", text: $name)
textFieldSection(title: "E-mail", placeholder: "seu@email.com", text: $email)
.appNoAutoCap()
textFieldSection(title: "Telefone", placeholder: "(00) 00000-0000", text: $phone)
.onChange(of: phone) { _, newValue in
let masked = formatPhoneBR(displayPhoneDigits(newValue))
if masked != newValue {
phone = masked
}
}
textFieldSection(title: "CPF", placeholder: "000.000.000-00", text: $cpf)
.keyboardType(.numberPad)
.onChange(of: cpf) { _, newValue in
let digits = newValue.filter(\.isNumber)
let masked = formatCPF(digits)
if masked != newValue { cpf = masked }
}
.appNoAutoCap()
}
.padding(16)
.background(AppColors.surface)
.clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous))
}
private var preferencesSection: some View {
VStack(alignment: .leading, spacing: 14) {
toggleRow(
icon: "bell.fill",
title: "Notificações",
isOn: $notificationsEnabled,
isDisabled: isTogglingNotifications
)
.onChange(of: notificationsEnabled) { _, newValue in
Task { await handleNotificationsToggle(newValue) }
}
Divider()
toggleRow(
icon: "faceid",
title: "Login com biometria",
isOn: $faceIdEnabled,
isDisabled: isTogglingFaceId
)
.onChange(of: faceIdEnabled) { _, newValue in
Task { await handleFaceIdToggle(newValue) }
}
}
.padding(16)
.background(AppColors.surface)
.clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous))
}
private func toggleRow(icon: String, title: String, isOn: Binding<Bool>, isDisabled: Bool) -> some View {
HStack(spacing: 12) {
Image(systemName: icon)
.font(.system(size: 18, weight: .semibold))
.foregroundStyle(AppColors.primary)
.frame(width: 28)
Text(title)
.font(AppTypography.body)
.foregroundStyle(AppColors.textPrimary)
Spacer(minLength: 10)
Toggle("", isOn: isOn)
.labelsHidden()
.disabled(isDisabled)
}
}
private var saveButton: some View {
Button(isSaving ? "Salvando..." : "Salvar Alterações") {
Task { await saveProfile() }
}
.font(AppTypography.button)
.foregroundStyle(AppColors.textInverse)
.frame(maxWidth: .infinity, minHeight: 56)
.background(AppColors.primary)
.clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous))
.buttonStyle(.plain)
.disabled(isSaving || canSave == false)
.opacity((isSaving || canSave == false) ? 0.6 : 1.0)
}
private var resolvedProfilePicture: String? {
let trimmed = profilePicture.trimmingCharacters(in: .whitespacesAndNewlines)
guard trimmed.isEmpty == false else { return nil }
return ImageSourceResolver.resolve(trimmed)
}
private var initials: String {
let parts = name
.trimmingCharacters(in: .whitespacesAndNewlines)
.split(separator: " ")
.prefix(2)
let letters = parts.compactMap { $0.first }.map(String.init).joined()
return letters.isEmpty ? "PF" : letters.uppercased()
}
private var canSave: Bool {
let cleanName = name.trimmingCharacters(in: .whitespacesAndNewlines)
let cleanEmail = email.trimmingCharacters(in: .whitespacesAndNewlines)
let normalizedPhone = normalizePhoneNumberForAPI(phone)
return cleanName.isEmpty == false
&& cleanEmail.isEmpty == false
&& cleanEmail.contains("@")
&& normalizedPhone.isEmpty == false
}
private func hydrateFromAppState() {
name = appState.profile.name
email = appState.profile.email
phone = formatPhoneForDisplay(appState.profile.phone)
profilePicture = appState.profile.profilePicture
cpf = formatCPF(appState.profile.cpf.filter(\.isNumber))
notificationsEnabled = appState.profile.notificationsEnabled
faceIdEnabled = appState.profile.faceIdEnabled
}
/// Touchpoint 1 of docs/api/push-notifications-integration-guide.md §2b
/// request OS permission (if needed) before flipping the server-side flag;
/// revert the toggle and explain why if the OS denies it.
@MainActor
private func handleNotificationsToggle(_ enabled: Bool) async {
guard isTogglingNotifications == false, enabled != appState.profile.notificationsEnabled else { return }
isTogglingNotifications = true
defer { isTogglingNotifications = false }
if enabled {
if let profile = await PushNotificationCoordinator.shared.enableNotifications() {
let serverValue = profile.notificationsEnabled ?? false
appState.profile.notificationsEnabled = serverValue
notificationsEnabled = serverValue
} else {
notificationsEnabled = false
showNotificationsDeniedAlert = true
}
return
}
do {
let response = try await ApiService().updateNotificationsEnabled(false)
if response.error {
notificationsEnabled = true
SnackbarCenter.shared.show(
title: response.message ?? "Não foi possível atualizar suas notificações.",
style: .error,
icon: "xmark.octagon.fill",
duration: 3.0
)
} else {
let serverValue = response.result?.notificationsEnabled ?? false
appState.profile.notificationsEnabled = serverValue
notificationsEnabled = serverValue
}
} catch {
notificationsEnabled = true
SnackbarCenter.shared.show(
title: "Não foi possível atualizar suas notificações.",
style: .error,
icon: "xmark.octagon.fill",
duration: 3.0
)
}
}
private func openSystemSettings() {
if let url = URL(string: UIApplication.openSettingsURLString) {
openURL(url)
}
}
/// Preference only for now the actual Face ID/Touch ID unlock flow
/// (LocalAuthentication) is a separate, later plan.
@MainActor
private func handleFaceIdToggle(_ enabled: Bool) async {
guard isTogglingFaceId == false, enabled != appState.profile.faceIdEnabled else { return }
isTogglingFaceId = true
defer { isTogglingFaceId = false }
do {
let response = try await ApiService().updateFaceIdEnabled(enabled)
if response.error {
faceIdEnabled = appState.profile.faceIdEnabled
SnackbarCenter.shared.show(
title: response.message ?? "Não foi possível atualizar essa preferência.",
style: .error,
icon: "xmark.octagon.fill",
duration: 3.0
)
} else {
let serverValue = response.result?.faceIdEnabled ?? enabled
appState.profile.faceIdEnabled = serverValue
faceIdEnabled = serverValue
}
} catch {
faceIdEnabled = appState.profile.faceIdEnabled
SnackbarCenter.shared.show(
title: "Não foi possível atualizar essa preferência.",
style: .error,
icon: "xmark.octagon.fill",
duration: 3.0
)
}
}
private func formatCPF(_ digits: String) -> String {
let d = String(digits.prefix(11))
if d.count <= 3 { return d }
if d.count <= 6 { return "\(d.prefix(3)).\(d.dropFirst(3))" }
if d.count <= 9 { return "\(d.prefix(3)).\(d.dropFirst(3).prefix(3)).\(d.dropFirst(6))" }
return "\(d.prefix(3)).\(d.dropFirst(3).prefix(3)).\(d.dropFirst(6).prefix(3))-\(d.dropFirst(9))"
}
@MainActor
private func saveProfile() async {
guard canSave else { return }
let cleanName = name.trimmingCharacters(in: .whitespacesAndNewlines)
let cleanEmail = email.trimmingCharacters(in: .whitespacesAndNewlines)
let normalizedPhone = normalizePhoneNumberForAPI(phone)
let cleanPhoto = profilePicture.trimmingCharacters(in: .whitespacesAndNewlines)
let newPhoto = cleanPhoto.hasPrefix("data:") ? cleanPhoto : nil
isSaving = true
defer { isSaving = false }
do {
let response = try await ApiService().updateCustomerProfile(
name: cleanName,
email: cleanEmail,
phoneNumber: normalizedPhone,
profilePicture: newPhoto
)
if response.error {
SnackbarCenter.shared.show(
title: response.message ?? "Não foi possível atualizar seu perfil.",
style: .error,
icon: "xmark.octagon.fill",
duration: 3.0
)
return
}
appState.profile.name = cleanName
appState.profile.email = cleanEmail
appState.profile.phone = normalizedPhone
if let pictureUrl = response.profilePictureUrl, pictureUrl.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false {
appState.profile.profilePicture = ImageSourceResolver.resolve(pictureUrl) ?? pictureUrl
} else if cleanPhoto.isEmpty == false {
appState.profile.profilePicture = cleanPhoto
}
let cleanCpf = cpf.filter(\.isNumber)
if cleanCpf.count == 11 {
guard isValidCPF(cleanCpf) else {
SnackbarCenter.shared.show(
title: "CPF inválido. Verifique e tente novamente.",
style: .error,
icon: "xmark.octagon.fill",
duration: 3.0
)
return
}
let cpfResponse = try await ApiService().updateProfileCpf(cpf: cleanCpf)
if cpfResponse.error {
SnackbarCenter.shared.show(
title: cpfResponse.message ?? "Não foi possível atualizar o CPF.",
style: .error,
icon: "xmark.octagon.fill",
duration: 3.0
)
return
}
appState.profile.cpf = cleanCpf
}
SessionStateStore.setActiveUserKey(
SessionStateStore.makeUserKey(profileId: appState.profile.id, email: appState.profile.email)
)
SnackbarCenter.shared.show(
title: "Perfil atualizado com sucesso.",
style: .success,
icon: "checkmark.circle.fill",
duration: 2.0
)
dismiss()
} catch {
SnackbarCenter.shared.show(
title: "Não foi possível atualizar seu perfil.",
style: .error,
icon: "xmark.octagon.fill",
duration: 3.0
)
}
}
private func formatPhoneForDisplay(_ raw: String) -> String {
let digits = displayPhoneDigits(raw)
if digits.isEmpty { return "" }
return formatPhoneBR(digits)
}
private func displayPhoneDigits(_ raw: String) -> String {
var digits = raw.filter(\.isNumber)
if digits.hasPrefix("55"), digits.count > 11 {
digits = String(digits.dropFirst(2))
}
return String(digits.prefix(11))
}
private func textFieldSection(title: String, placeholder: String, text: Binding<String>) -> some View {
VStack(alignment: .leading, spacing: 8) {
Text(title)
.font(AppTypography.caption)
.foregroundStyle(AppColors.textMuted)
TextField(placeholder, text: text)
.font(AppTypography.body)
.foregroundStyle(AppColors.textPrimary)
.padding(.horizontal, 12)
.frame(height: 50)
.background(AppColors.backgroundLight)
.clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: 14, style: .continuous)
.stroke(AppColors.secondary.opacity(0.2), lineWidth: 1)
)
}
}
private func isValidCPF(_ digits: String) -> Bool {
guard digits.count == 11, digits.unicodeScalars.allSatisfy({ CharacterSet.decimalDigits.contains($0) }) else { return false }
guard Set(digits).count > 1 else { return false }
func checkDigit(_ d: String, _ length: Int) -> Bool {
let sum = d.prefix(length).enumerated().reduce(0) { acc, pair in
acc + (Int(String(pair.element)) ?? 0) * (length + 1 - pair.offset)
}
let rem = (sum * 10) % 11
let expected = rem == 10 ? 0 : rem
return Int(String(d[d.index(d.startIndex, offsetBy: length)])) == expected
}
return checkDigit(digits, 9) && checkDigit(digits, 10)
}
#if os(iOS)
@MainActor
private func applySelectedPhoto(_ item: PhotosPickerItem?) async {
guard let item else { return }
do {
guard let data = try await item.loadTransferable(type: Data.self) 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(
title: "Não foi possível carregar a foto selecionada.",
style: .warning,
icon: "photo",
duration: 2.5
)
}
}
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
}