segregate files

This commit is contained in:
Daniel Arantes Loverde
2026-02-13 11:01:54 -03:00
parent 4a40936293
commit d7b8b1864a
20 changed files with 2653 additions and 1923 deletions

View File

@@ -0,0 +1,251 @@
import SwiftUI
struct AddAddressFormView: View {
@Environment(\.dismiss) var dismiss
@Environment(\.colorScheme) var colorScheme
@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
}
@ViewBuilder private var logoImage: some View {
#if os(Android)
SwiftUI.Image("pedifoods")
.resizable()
#else
SwiftUI.Image("pedifoods")
.resizable()
#endif
}
var body: some View {
ZStack {
(colorScheme == .dark ? Color.black : AppColors.backgroundLight).ignoresSafeArea()
ScrollView {
VStack(spacing: 0) {
logoImage
.scaledToFit()
.frame(width: 120, height: 120)
Text(existingAddress == nil ? "Novo endereço" : "Editar endereço")
.font(AppTypography.heading1)
.foregroundStyle(colorScheme == .dark ? Color.white : AppColors.textPrimary)
Text(existingAddress == nil ? "Preencha os dados abaixo para adicionar um endereço." : "Atualize os dados do endereço abaixo.")
.font(AppTypography.body)
.foregroundStyle(colorScheme == .dark ? Color.white : AppColors.textPrimary)
.multilineTextAlignment(.center)
.padding(.top, 8)
.padding(.bottom, 20)
VStack(alignment: .leading, spacing: 16) {
LoginField(icon: "tag", placeholder: "Ex: Casa, Trabalho", text: $label)
LoginField(icon: "mail", placeholder: "CEP", 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", text: $address)
LoginField(icon: "number", placeholder: "Número", text: $number)
LoginField(icon: "plus.app", placeholder: "Complemento (opcional)", text: $complement)
LoginField(icon: "square.grid.2x2", placeholder: "Bairro", text: $neighborhood)
LoginField(icon: "building.2", placeholder: "Cidade", text: $city)
LoginField(icon: "map", placeholder: "Estado (UF)", 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, 0)
}
.navigationBarBackButtonHidden(true)
.toolbar(.hidden, for: .navigationBar)
.onAppear {
populateFromExistingAddressIfNeeded()
}
}
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
)
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,216 @@
import SwiftUI
#if canImport(UIKit)
import UIKit
#endif
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() {
#if canImport(UIKit)
UIImpactFeedbackGenerator(style: .light).impactOccurred()
#endif
}
func triggerSelectionHaptic() {
#if canImport(UIKit)
UISelectionFeedbackGenerator().selectionChanged()
#endif
}
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 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)
.truncationMode(.tail)
.minimumScaleFactor(0.9)
if item.isPrimary {
Text("PRINCIPAL")
.font(AppTypography.overline)
.foregroundStyle(AppColors.textPrimary)
.lineLimit(1)
.fixedSize(horizontal: true, vertical: false)
.padding(.horizontal, 9)
.padding(.vertical, 5)
.background(AppColors.tertiary)
.clipShape(Capsule())
}
}
Text(item.detail)
.font(AppTypography.body)
.foregroundStyle(AppColors.textMuted)
.lineLimit(2)
}
Spacer(minLength: 8)
if onEdit != nil || onDelete != nil {
Rectangle()
.fill(AppColors.backgroundLight)
.frame(width: 1, height: 96)
}
VStack(spacing: 24) {
if let onEdit {
Button(action: onEdit) {
Image(systemName: "pencil")
.font(.system(size: 22))
.foregroundStyle(AppColors.textMuted)
}
}
if let onDelete {
Button(action: onDelete) {
Image(systemName: "trash")
.font(.system(size: 22))
.foregroundStyle(AppColors.textMuted)
}
}
}
.frame(width: onEdit != nil || onDelete != nil ? 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
}
struct OrdersView: View {
var body: some View {
VStack(spacing: 16) {
Text("Pedidos")
.font(AppTypography.heading1)
Text("Historico vazio")
.foregroundStyle(AppColors.secondary)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(AppColors.backgroundLight)
}
}

View File

@@ -0,0 +1,339 @@
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
let tabBarClearance: CGFloat = 96
var body: some View {
ZStack {
AppColors.backgroundLight
.ignoresSafeArea()
ScrollView(showsIndicators: false) {
VStack(spacing: 20) {
header
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 isSelected: Bool = {
if let selectedId = appState.address.selectedId {
return address.id == selectedId
}
return index == 0
}()
if selectionMode {
Button {
selectAddress(address)
} label: {
AddressCard(item: addressToListItem(address, isPrimary: isSelected))
}
.buttonStyle(.plain)
} else {
SwipeToDeleteAddressRow(
rowId: rowId,
openRowId: $openSwipeRowId,
isDeleting: deletingRowId == rowId,
onDelete: { deleteAddress(address, rowId: rowId) }
) {
AddressCard(
item: addressToListItem(address, isPrimary: isSelected),
onEdit: { beginEditing(address) }
)
.contentShape(Rectangle())
.onTapGesture {
if openSwipeRowId == rowId {
openSwipeRowId = nil
}
}
}
.id(rowId)
.opacity(deletingRowId == rowId ? 0.6 : 1.0)
.disabled(deletingRowId != nil)
}
}
}
}
}
.padding(.horizontal, 20)
.padding(.top, 18)
}
VStack {
Spacer()
bottomOverlay
.padding(.bottom, tabBarClearance)
}
}
.navigationBarBackButtonHidden(true)
.toolbar(.hidden, for: .navigationBar)
.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 header: some View {
ZStack {
Text("Meus Endereços")
.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)
}
Spacer()
}
}
}
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 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 = updatedAddresses.first(where: { $0.id == appState.address.selectedId }) ?? updatedAddresses.first
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 deleteAddress(_ address: CustomerAddress, rowId: String) {
guard 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()
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
SessionStateStore.setActiveUserKey(
SessionStateStore.makeUserKey(profileId: customer.id, email: customer.email)
)
addresses = customer.addressBook ?? []
} else {
addresses = []
}
if let selected = addresses.first(where: { $0.id == appState.address.selectedId }) ?? addresses.first {
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
}
}

View File

@@ -1,15 +1,127 @@
import Foundation
import SwiftUI
struct CartView: View {
@Binding var appState: AppState
var body: some View {
VStack(spacing: 16) {
VStack(spacing: 14) {
Text("Carrinho")
.font(AppTypography.heading1)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 20)
.padding(.top, 20)
Text("Seu carrinho esta vazio")
.foregroundStyle(AppColors.secondary)
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, maxHeight: .infinity, alignment: .center)
} else {
ScrollView(showsIndicators: false) {
VStack(spacing: 12) {
if let storeName = appState.cart.storeName, storeName.isEmpty == false {
Text(storeName)
.font(AppTypography.heading3)
.foregroundStyle(AppColors.textPrimary)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 20)
}
ForEach(appState.cart.items) { item in
cartItemRow(item)
}
}
.padding(.bottom, 120)
}
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(AppColors.backgroundLight)
.safeAreaInset(edge: .bottom) {
if appState.cart.items.isEmpty == false {
VStack(spacing: 10) {
HStack {
Text("Total")
.font(AppTypography.heading3)
.foregroundStyle(AppColors.textPrimary)
Spacer()
Text(formatCurrency(appState.cart.total))
.font(AppTypography.heading2)
.foregroundStyle(AppColors.primary)
}
.padding(.horizontal, 20)
PrimaryButton(title: "Finalizar pedido", action: {})
.padding(.horizontal, 20)
}
.padding(.top, 10)
.padding(.bottom, 12)
.background(.ultraThinMaterial)
}
}
}
private func cartItemRow(_ item: CartItemState) -> some View {
HStack(spacing: 12) {
VStack(alignment: .leading, spacing: 4) {
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: 8) {
Button(action: { appState.cart.decrement(itemId: item.id) }) {
Image(systemName: "minus")
.font(.system(size: 13, weight: .bold))
.foregroundStyle(AppColors.textPrimary)
.frame(width: 28, height: 28)
.background(AppColors.brandSoft)
.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: 28, height: 28)
.background(AppColors.tertiary)
.clipShape(Circle())
}
.buttonStyle(.plain)
}
}
.padding(.horizontal, 14)
.padding(.vertical, 12)
.background(AppColors.surface)
.clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous))
.padding(.horizontal, 20)
}
private func formatCurrency(_ value: Double) -> String {
String(format: "R$ %.2f", value).replacingOccurrences(of: ".", with: ",")
}
}

View File

@@ -0,0 +1,76 @@
import SwiftUI
#if canImport(UIKit)
import UIKit
#endif
#if os(iOS)
@MainActor
struct ScrollOffsetObserver: UIViewRepresentable {
let onOffsetChange: (CGFloat) -> Void
func makeUIView(context: Context) -> ScrollOffsetProbeView {
let view = ScrollOffsetProbeView()
view.onOffsetChange = onOffsetChange
return view
}
func updateUIView(_ uiView: ScrollOffsetProbeView, context: Context) {
uiView.onOffsetChange = onOffsetChange
uiView.attachIfNeeded()
}
}
@MainActor
final class ScrollOffsetProbeView: UIView {
var onOffsetChange: (CGFloat) -> Void = { _ in }
private weak var observedScrollView: UIScrollView?
private var observation: NSKeyValueObservation?
override init(frame: CGRect) {
super.init(frame: frame)
backgroundColor = .clear
isUserInteractionEnabled = false
}
required init?(coder: NSCoder) {
super.init(coder: coder)
}
deinit {
observation?.invalidate()
}
override func didMoveToSuperview() {
super.didMoveToSuperview()
attachIfNeeded()
}
override func didMoveToWindow() {
super.didMoveToWindow()
attachIfNeeded()
}
func attachIfNeeded() {
guard let scrollView = findEnclosingScrollView() else { return }
guard scrollView !== observedScrollView else { return }
observation?.invalidate()
observedScrollView = scrollView
observation = scrollView.observe(\.contentOffset, options: [.initial, .new]) { [weak self] sv, _ in
self?.onOffsetChange(sv.contentOffset.y)
}
}
private func findEnclosingScrollView() -> UIScrollView? {
var view: UIView? = self
while let current = view {
if let scrollView = current as? UIScrollView {
return scrollView
}
view = current.superview
}
return nil
}
}
#endif

View File

@@ -0,0 +1,126 @@
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]) async {
do {
let response = try await ApiService().listPublicCategories()
if response.error == false, let remote = response.result, remote.isEmpty == false {
categories = mapPublicCategories(remote)
return
}
} catch {
// Fallback handled below.
}
categories = buildCategories(from: stores)
}
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)? {
if !forceRefresh {
if let lat = appState.address.latitude, let lng = appState.address.longitude {
return (lat, lng)
}
if let cached = LocationService.shared.cachedLocation() {
appState.address.latitude = cached.0
appState.address.longitude = cached.1
return cached
}
if hasConfiguredAddress() == false {
return nil
}
}
let coordinate = await LocationService.shared.requestLocationAsync(timeoutSeconds: 3)
if let coordinate {
appState.address.latitude = coordinate.0
appState.address.longitude = coordinate.1
}
return coordinate
}
func hasConfiguredAddress() -> Bool {
if appState.address.selectedId != nil {
return true
}
if appState.address.latitude != nil, appState.address.longitude != 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."
}
}

View File

@@ -91,7 +91,8 @@ struct HomeView: View {
storeLogoURL: store.logoURL,
storeCategory: store.category,
storeRating: store.rating,
storeDistance: store.distance
storeDistance: store.distance,
appState: $appState
)
} label: {
FeaturedStoreCard(store: store)
@@ -159,7 +160,8 @@ struct HomeView: View {
storeLogoURL: store.logoURL,
storeCategory: store.category,
storeRating: store.rating,
storeDistance: store.distance
storeDistance: store.distance,
appState: $appState
)
} label: {
FeaturedStoreCard(store: store)
@@ -410,127 +412,6 @@ struct HomeView: View {
}
}
private 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
private func loadHomeCategories(withFallbackStores stores: [StoreSummary]) async {
do {
let response = try await ApiService().listPublicCategories()
if response.error == false, let remote = response.result, remote.isEmpty == false {
categories = mapPublicCategories(remote)
return
}
} catch {
// Fallback handled below.
}
categories = buildCategories(from: stores)
}
private 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
}
private 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
private func resolveCoordinates(forceRefresh: Bool) async -> (Double, Double)? {
if !forceRefresh {
if let lat = appState.address.latitude, let lng = appState.address.longitude {
return (lat, lng)
}
if let cached = LocationService.shared.cachedLocation() {
appState.address.latitude = cached.0
appState.address.longitude = cached.1
return cached
}
if hasConfiguredAddress() == false {
return nil
}
}
let coordinate = await LocationService.shared.requestLocationAsync(timeoutSeconds: 3)
if let coordinate {
appState.address.latitude = coordinate.0
appState.address.longitude = coordinate.1
}
return coordinate
}
private func hasConfiguredAddress() -> Bool {
if appState.address.selectedId != nil {
return true
}
if appState.address.latitude != nil, appState.address.longitude != nil {
return true
}
let normalized = appState.address.display
.trimmingCharacters(in: .whitespacesAndNewlines)
.lowercased()
return normalized.isEmpty == false && normalized != "defina seu endereco"
}
private 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."
}
@ViewBuilder
private var scrollOffsetObserver: some View {
@@ -547,136 +428,3 @@ struct HomeView: View {
#endif
}
}
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.brandDark : 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()
Spacer()
Button(action: onFilterTap) {
Image(systemName: "slider.horizontal.3")
.foregroundStyle(AppColors.textMuted)
}
}
.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)
}
#if os(iOS)
@MainActor
private struct ScrollOffsetObserver: UIViewRepresentable {
let onOffsetChange: (CGFloat) -> Void
func makeUIView(context: Context) -> ScrollOffsetProbeView {
let view = ScrollOffsetProbeView()
view.onOffsetChange = onOffsetChange
return view
}
func updateUIView(_ uiView: ScrollOffsetProbeView, context: Context) {
uiView.onOffsetChange = onOffsetChange
uiView.attachIfNeeded()
}
}
@MainActor
private final class ScrollOffsetProbeView: UIView {
var onOffsetChange: (CGFloat) -> Void = { _ in }
private weak var observedScrollView: UIScrollView?
private var observation: NSKeyValueObservation?
override init(frame: CGRect) {
super.init(frame: frame)
backgroundColor = .clear
isUserInteractionEnabled = false
}
required init?(coder: NSCoder) {
super.init(coder: coder)
}
deinit {
observation?.invalidate()
}
override func didMoveToSuperview() {
super.didMoveToSuperview()
attachIfNeeded()
}
override func didMoveToWindow() {
super.didMoveToWindow()
attachIfNeeded()
}
func attachIfNeeded() {
guard let scrollView = findEnclosingScrollView() else { return }
guard scrollView !== observedScrollView else { return }
observation?.invalidate()
observedScrollView = scrollView
observation = scrollView.observe(\.contentOffset, options: [.initial, .new]) { [weak self] sv, _ in
self?.onOffsetChange(sv.contentOffset.y)
}
}
private func findEnclosingScrollView() -> UIScrollView? {
var view: UIView? = self
while let current = view {
if let scrollView = current as? UIScrollView {
return scrollView
}
view = current.superview
}
return nil
}
}
#endif

View File

@@ -0,0 +1,62 @@
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.brandDark : 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()
Spacer()
Button(action: onFilterTap) {
Image(systemName: "slider.horizontal.3")
.foregroundStyle(AppColors.textMuted)
}
}
.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

@@ -16,7 +16,7 @@ struct MainTabView: View {
}
case .cart:
NavigationStack {
CartView()
CartView(appState: $appState)
}
case .profile:
NavigationStack {

View File

@@ -0,0 +1,196 @@
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 selectedAddonItemIds: Set<String> = []
@State var quantity: Int = 0
private var selectedAddonItems: [StoreAddonItem] {
product.addonGroups
.flatMap(\.items)
.filter { selectedAddonItemIds.contains($0.id) }
}
private var addonsTotal: Double {
selectedAddonItems.reduce(0) { $0 + ($1.price ?? 0) }
}
private var unitPrice: Double {
(product.price ?? 0) + addonsTotal
}
private var totalPrice: Double {
unitPrice * Double(quantity)
}
private var cartItemId: String {
let addonKey = selectedAddonItemIds.sorted().joined(separator: ",")
return "\(storeId)::\(product.id)::\(addonKey)"
}
private var selectedAddonsSummary: String? {
let names = selectedAddonItems.map(\.name)
if names.isEmpty { return nil }
return names.joined(separator: ", ")
}
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) {
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)
}
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
Button {
if selectedAddonItemIds.contains(item.id) {
selectedAddonItemIds.remove(item.id)
} else {
selectedAddonItemIds.insert(item.id)
}
} label: {
HStack {
Image(systemName: selectedAddonItemIds.contains(item.id) ? "checkmark.circle.fill" : "circle")
.font(.system(size: 18, weight: .semibold))
.foregroundStyle(selectedAddonItemIds.contains(item.id) ? AppColors.primary : AppColors.textMuted)
Text(item.name)
.font(AppTypography.body)
.foregroundStyle(AppColors.textMuted)
Spacer()
Text(String(format: "+ R$ %.2f", item.price ?? 0).replacingOccurrences(of: ".", with: ","))
.font(AppTypography.body)
.foregroundStyle(AppColors.textPrimary)
}
}
.buttonStyle(.plain)
}
}
.padding(12)
.background(AppColors.surface)
.clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous))
}
}
}
.padding(20)
.padding(.bottom, 80)
}
.safeAreaInset(edge: .bottom) {
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,
details: selectedAddonsSummary,
quantity: quantity,
unitPrice: unitPrice
)
onAdd(item)
dismiss()
}
}
.padding(.horizontal, 20)
.padding(.top, 8)
.padding(.bottom, 12)
.background(.ultraThinMaterial)
}
.background(AppColors.backgroundLight.ignoresSafeArea())
.navigationTitle("Detalhes")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button("Fechar") { dismiss() }
.foregroundStyle(AppColors.primary)
}
}
.onAppear {
quantity = currentQuantityForItemId(cartItemId)
}
.onChange(of: selectedAddonItemIds) { _, _ in
quantity = currentQuantityForItemId(cartItemId)
}
}
private func formatCurrency(_ value: Double) -> String {
String(format: "R$ %.2f", value).replacingOccurrences(of: ".", with: ",")
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,164 @@
import SwiftUI
#if canImport(UIKit)
import UIKit
#endif
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 })
}
}
struct ScrollOffsetReader: View {
@Binding var offsetY: CGFloat
var body: some View {
#if canImport(UIKit)
ScrollOffsetReaderRepresentable(offsetY: $offsetY)
#else
Color.clear
#endif
}
}
#if canImport(UIKit)
struct ScrollOffsetReaderRepresentable: UIViewRepresentable {
@Binding var offsetY: CGFloat
func makeUIView(context: Context) -> OffsetProbeView {
let view = OffsetProbeView()
view.onOffsetChanged = { value in
if offsetY != value {
offsetY = value
}
}
return view
}
func updateUIView(_ uiView: OffsetProbeView, context: Context) {
uiView.onOffsetChanged = { value in
if offsetY != value {
offsetY = value
}
}
}
}
final class OffsetProbeView: UIView {
var onOffsetChanged: ((CGFloat) -> Void)?
private var observation: NSKeyValueObservation?
private weak var observedScrollView: UIScrollView?
override func didMoveToWindow() {
super.didMoveToWindow()
attachIfNeeded()
}
override func didMoveToSuperview() {
super.didMoveToSuperview()
attachIfNeeded()
}
private func attachIfNeeded() {
guard observation == nil else { return }
guard observedScrollView == nil else { return }
if let scrollView = enclosingScrollView() ?? findScrollViewInWindow() {
observe(scrollView)
return
}
retryAttach()
}
private func observe(_ scrollView: UIScrollView) {
observedScrollView = scrollView
observation = scrollView.observe(\.contentOffset, options: [.new, .initial]) { [weak self, weak scrollView] _, change in
guard let self, let scrollView, let y = change.newValue?.y else { return }
let adjusted = max(0, y + scrollView.adjustedContentInset.top)
DispatchQueue.main.async {
self.onOffsetChanged?(adjusted)
}
}
}
private func retryAttach() {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { [weak self] in
self?.attachIfNeeded()
}
}
private func enclosingScrollView() -> UIScrollView? {
var current: UIView? = self
while let view = current {
if let scrollView = view as? UIScrollView {
return scrollView
}
current = view.superview
}
return nil
}
private func findScrollViewInWindow() -> UIScrollView? {
guard let window else { return nil }
let targetPoint = convert(CGPoint(x: bounds.midX, y: bounds.midY), to: window)
return findScrollView(in: window, containing: targetPoint)
}
private func findScrollView(in root: UIView, containing point: CGPoint) -> UIScrollView? {
for subview in root.subviews.reversed() {
if let match = findScrollView(in: subview, containing: point) {
return match
}
}
if let scrollView = root as? UIScrollView {
let rectInWindow = scrollView.convert(scrollView.bounds, to: window)
if rectInWindow.contains(point) {
return scrollView
}
}
return nil
}
deinit {
observation?.invalidate()
}
}
#endif
struct AsyncStoreImage: View {
let imageURL: String?
var body: some View {
Group {
if let imageURL,
let url = URL(string: imageURL) {
AsyncImage(url: url) { phase in
switch phase {
case .success(let image):
image
.resizable()
.scaledToFill()
default:
fallback
}
}
} else {
fallback
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(AppColors.brandSoft)
.clipped()
}
private var fallback: some View {
Image("placeholder-product")
.resizable()
.scaledToFill()
}
}

View File

@@ -0,0 +1,227 @@
import SwiftUI
extension StoreDetailView {
func heroIconButton(icon: String, action: @escaping () -> Void) -> some View {
Button(action: action) {
Image(systemName: icon)
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(Color.white)
.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)
}
if let storeDistance, storeDistance.isEmpty == false {
return storeDistance
}
return "R$ --"
}
@MainActor
func loadStoreData() async {
isLoading = true
errorMessage = nil
do {
async let infoRequest = ApiService().storeInfo(storeId: storeId)
async let catalogRequest = ApiService().storeCatalog(storeId: storeId)
let (infoResponse, catalogResponse) = try await (infoRequest, catalogRequest)
if infoResponse.error {
errorMessage = infoResponse.message ?? "Não foi possível carregar a loja."
isLoading = false
return
}
if catalogResponse.error {
errorMessage = catalogResponse.message ?? "Não foi possível carregar o catálogo."
isLoading = false
return
}
info = infoResponse.result
categories = catalogResponse.result ?? []
selectedCategoryId = categories.first?.id
isLoading = false
} catch {
isLoading = false
if let network = error as? NetworkError {
errorMessage = network.errorDescription ?? "Erro ao carregar loja."
return
}
if let service = error as? ApiServiceError {
errorMessage = service.errorDescription ?? "Erro ao carregar loja."
return
}
errorMessage = "Erro ao carregar loja."
}
}
func resolvedURL(_ raw: String?) -> String? {
guard let raw = raw?.trimmingCharacters(in: .whitespacesAndNewlines),
raw.isEmpty == false else { return nil }
let lower = raw.lowercased()
if lower.hasPrefix("http://") || lower.hasPrefix("https://") {
return raw
}
let base = ApiConfig.baseURL.absoluteString.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
let path = raw.hasPrefix("/") ? raw : "/\(raw)"
return "\(base)\(path)"
}
func formatCurrency(_ value: Double?) -> String {
guard let value else { return "R$ --" }
return String(format: "R$ %.2f", value).replacingOccurrences(of: ".", with: ",")
}
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 requestAddToCart(_ item: CartItemState) {
pendingCartAction = .add
if shouldAskForStoreSwitch(for: storeId) {
pendingCartItem = item
pendingProductSheet = nil
showSwitchStoreAlert = true
return
}
applyAddToCart(item)
}
func requestSetCartItem(_ item: CartItemState) {
pendingCartAction = .set
if shouldAskForStoreSwitch(for: storeId) && item.quantity > 0 {
pendingCartItem = item
pendingProductSheet = nil
showSwitchStoreAlert = true
return
}
applySetCartItem(item)
}
func requestOpenProductSheet(_ product: StoreCatalogProduct) {
pendingCartAction = .openProductSheet
if shouldAskForStoreSwitch(for: storeId) {
pendingCartItem = nil
pendingProductSheet = product
showSwitchStoreAlert = true
return
}
selectedProduct = product
}
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
}

View File

@@ -12,6 +12,7 @@ struct StoreDetailView: View {
let storeCategory: String?
let storeRating: Double?
let storeDistance: String?
@Binding var appState: AppState
@Environment(\.dismiss) var dismiss
@@ -21,6 +22,10 @@ struct StoreDetailView: View {
@State var categories: [StoreCatalogCategory] = []
@State var selectedCategoryId: String? = nil
@State var selectedProduct: StoreCatalogProduct? = nil
@State var showSwitchStoreAlert = false
@State var pendingCartItem: CartItemState? = nil
@State var pendingProductSheet: StoreCatalogProduct? = nil
@State var pendingCartAction: CartAction = .add
@State var didLoad = false
@State var categoryHeaderOffsets: [String: CGFloat] = [:]
@State var isProgrammaticCategoryScroll = false
@@ -71,9 +76,43 @@ struct StoreDetailView: View {
}
.sheet(item: $selectedProduct) { product in
NavigationStack {
ProductDetailSheet(product: product, imageURL: resolvedURL(product.image))
ProductDetailSheet(
product: product,
imageURL: resolvedURL(product.image),
storeId: storeId,
currentQuantityForItemId: { itemId in
currentQuantity(forCartItemId: itemId)
},
onAdd: { item in
requestSetCartItem(item)
}
)
}
}
.alert("Trocar de loja?", isPresented: $showSwitchStoreAlert) {
Button("Cancelar", role: .cancel) {
pendingCartItem = nil
pendingProductSheet = 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
}
self.pendingCartItem = nil
self.pendingProductSheet = 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()
@@ -322,14 +361,44 @@ struct StoreDetailView: View {
.clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous))
Button {
selectedProduct = product
if product.addonGroups.isEmpty {
let basePrice = product.price ?? 0
let item = CartItemState(
id: "\(storeId)::\(product.id)::base",
productId: product.id,
storeId: storeId,
name: product.name,
quantity: 1,
unitPrice: basePrice
)
requestAddToCart(item)
} else {
requestOpenProductSheet(product)
}
} label: {
Image(systemName: "plus")
.font(.system(size: 16, weight: .bold))
.foregroundStyle(AppColors.textPrimary)
.frame(width: 30, height: 30)
.background(AppColors.tertiary)
.clipShape(Circle())
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: product.id)
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)
}
.buttonStyle(.plain)
.offset(x: 7, y: 7)
@@ -340,391 +409,4 @@ struct StoreDetailView: View {
.clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous))
}
private func heroIconButton(icon: String, action: @escaping () -> Void) -> some View {
Button(action: action) {
Image(systemName: icon)
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(Color.white)
.frame(width: 32, height: 32)
.background(Color.white.opacity(0.24))
.clipShape(Circle())
}
.buttonStyle(.plain)
}
private 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)
}
private 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())
}
private var storeSubtitle: String {
let category = (storeCategory ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
if category.isEmpty { return "Restaurant" }
return category
}
private var deliveryValueLabel: String {
if let minOrder = info?.minOrder {
return formatCurrency(minOrder)
}
if let storeDistance, storeDistance.isEmpty == false {
return storeDistance
}
return "R$ --"
}
@MainActor
private func loadStoreData() async {
isLoading = true
errorMessage = nil
do {
async let infoRequest = ApiService().storeInfo(storeId: storeId)
async let catalogRequest = ApiService().storeCatalog(storeId: storeId)
let (infoResponse, catalogResponse) = try await (infoRequest, catalogRequest)
if infoResponse.error {
errorMessage = infoResponse.message ?? "Não foi possível carregar a loja."
isLoading = false
return
}
if catalogResponse.error {
errorMessage = catalogResponse.message ?? "Não foi possível carregar o catálogo."
isLoading = false
return
}
info = infoResponse.result
categories = catalogResponse.result ?? []
selectedCategoryId = categories.first?.id
isLoading = false
} catch {
isLoading = false
if let network = error as? NetworkError {
errorMessage = network.errorDescription ?? "Erro ao carregar loja."
return
}
if let service = error as? ApiServiceError {
errorMessage = service.errorDescription ?? "Erro ao carregar loja."
return
}
errorMessage = "Erro ao carregar loja."
}
}
private func resolvedURL(_ raw: String?) -> String? {
guard let raw = raw?.trimmingCharacters(in: .whitespacesAndNewlines),
raw.isEmpty == false else { return nil }
let lower = raw.lowercased()
if lower.hasPrefix("http://") || lower.hasPrefix("https://") {
return raw
}
let base = ApiConfig.baseURL.absoluteString.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
let path = raw.hasPrefix("/") ? raw : "/\(raw)"
return "\(base)\(path)"
}
private func formatCurrency(_ value: Double?) -> String {
guard let value else { return "R$ --" }
return String(format: "R$ %.2f", value).replacingOccurrences(of: ".", with: ",")
}
private var topSectionHeight: CGFloat {
cardTopInset + summaryCardHeight
}
private func sectionAnchorId(for categoryId: String) -> String {
"category-section-\(categoryId)"
}
private 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
}
}
}
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 })
}
}
struct ScrollOffsetReader: View {
@Binding var offsetY: CGFloat
var body: some View {
#if canImport(UIKit)
ScrollOffsetReaderRepresentable(offsetY: $offsetY)
#else
Color.clear
#endif
}
}
#if canImport(UIKit)
struct ScrollOffsetReaderRepresentable: UIViewRepresentable {
@Binding var offsetY: CGFloat
func makeUIView(context: Context) -> OffsetProbeView {
let view = OffsetProbeView()
view.onOffsetChanged = { value in
if offsetY != value {
offsetY = value
}
}
return view
}
func updateUIView(_ uiView: OffsetProbeView, context: Context) {
uiView.onOffsetChanged = { value in
if offsetY != value {
offsetY = value
}
}
}
}
final class OffsetProbeView: UIView {
var onOffsetChanged: ((CGFloat) -> Void)?
private var observation: NSKeyValueObservation?
private weak var observedScrollView: UIScrollView?
override func didMoveToWindow() {
super.didMoveToWindow()
attachIfNeeded()
}
override func didMoveToSuperview() {
super.didMoveToSuperview()
attachIfNeeded()
}
private func attachIfNeeded() {
guard observation == nil else { return }
guard observedScrollView == nil else { return }
if let scrollView = enclosingScrollView() ?? findScrollViewInWindow() {
observe(scrollView)
return
}
retryAttach()
}
private func observe(_ scrollView: UIScrollView) {
observedScrollView = scrollView
observation = scrollView.observe(\.contentOffset, options: [.new, .initial]) { [weak self, weak scrollView] _, change in
guard let self, let scrollView, let y = change.newValue?.y else { return }
let adjusted = max(0, y + scrollView.adjustedContentInset.top)
DispatchQueue.main.async {
self.onOffsetChanged?(adjusted)
}
}
}
private func retryAttach() {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { [weak self] in
self?.attachIfNeeded()
}
}
private func enclosingScrollView() -> UIScrollView? {
var current: UIView? = self
while let view = current {
if let scrollView = view as? UIScrollView {
return scrollView
}
current = view.superview
}
return nil
}
private func findScrollViewInWindow() -> UIScrollView? {
guard let window else { return nil }
let targetPoint = convert(CGPoint(x: bounds.midX, y: bounds.midY), to: window)
return findScrollView(in: window, containing: targetPoint)
}
private func findScrollView(in root: UIView, containing point: CGPoint) -> UIScrollView? {
for subview in root.subviews.reversed() {
if let match = findScrollView(in: subview, containing: point) {
return match
}
}
if let scrollView = root as? UIScrollView {
let rectInWindow = scrollView.convert(scrollView.bounds, to: window)
if rectInWindow.contains(point) {
return scrollView
}
}
return nil
}
deinit {
observation?.invalidate()
}
}
#endif
struct AsyncStoreImage: View {
let imageURL: String?
var body: some View {
Group {
if let imageURL,
let url = URL(string: imageURL) {
AsyncImage(url: url) { phase in
switch phase {
case .success(let image):
image
.resizable()
.scaledToFill()
default:
fallback
}
}
} else {
fallback
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(AppColors.brandSoft)
.clipped()
}
private var fallback: some View {
Image("placeholder-product")
.resizable()
.scaledToFill()
}
}
struct ProductDetailSheet: View {
let product: StoreCatalogProduct
let imageURL: String?
@Environment(\.dismiss) var dismiss
var body: some View {
ScrollView(showsIndicators: false) {
VStack(alignment: .leading, spacing: 16) {
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(String(format: "R$ %.2f", product.price ?? 0).replacingOccurrences(of: ".", with: ","))
.font(AppTypography.heading2)
.foregroundStyle(AppColors.primary)
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 {
Text(item.name)
.font(AppTypography.body)
.foregroundStyle(AppColors.textMuted)
Spacer()
Text(String(format: "+ R$ %.2f", item.price ?? 0).replacingOccurrences(of: ".", with: ","))
.font(AppTypography.body)
.foregroundStyle(AppColors.textPrimary)
}
}
}
.padding(12)
.background(AppColors.surface)
.clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous))
}
}
}
.padding(20)
.padding(.bottom, 80)
}
.safeAreaInset(edge: .bottom) {
HStack(spacing: 12) {
Button(action: {}) {
Image(systemName: "bag")
.font(.system(size: 16, weight: .bold))
.foregroundStyle(AppColors.textPrimary)
.frame(width: 48, height: 48)
.background(AppColors.surface)
.clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous))
}
.buttonStyle(.plain)
PrimaryButton(title: "Comprar agora", action: {})
}
.padding(.horizontal, 20)
.padding(.top, 8)
.padding(.bottom, 12)
.background(.ultraThinMaterial)
}
.background(AppColors.backgroundLight.ignoresSafeArea())
.navigationTitle("Detalhes")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button("Fechar") { dismiss() }
.foregroundStyle(AppColors.primary)
}
}
}
}