Commit
This commit is contained in:
446
Sources/PediFoods/Views/Main/AddressesView.swift
Normal file
446
Sources/PediFoods/Views/Main/AddressesView.swift
Normal file
@@ -0,0 +1,446 @@
|
||||
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 ?? ""
|
||||
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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user