swipe to delete address

This commit is contained in:
Daniel Arantes Loverde
2026-02-12 18:39:02 -03:00
parent c7740174d1
commit 4a40936293
4 changed files with 348 additions and 128 deletions

View File

@@ -3,6 +3,9 @@
"strings" : {
"" : {
},
"..." : {
},
"·" : {
"comment" : "A period character used to separate different pieces of information in a list.",
@@ -118,6 +121,10 @@
}
}
},
"Atualize os dados do endereço abaixo." : {
"comment" : "A description below the fields in the \"Editar endereço\" form, instructing the user to update their address details.",
"isCommentAutoGenerated" : true
},
"Boas-vindas!" : {
"comment" : "A welcome message displayed in the login view.",
"isCommentAutoGenerated" : true
@@ -279,6 +286,9 @@
"DL" : {
"comment" : "An abbreviation for \"Delivery Lady\" used in the user's profile picture circle.",
"isCommentAutoGenerated" : true
},
"Editar endereço" : {
},
"Enderecos" : {
"comment" : "A link to the user's address list.",
@@ -292,6 +302,10 @@
"comment" : "A description below the login fields, explaining that a verification code will be sent via SMS or email to confirm access.",
"isCommentAutoGenerated" : true
},
"Excluir" : {
"comment" : "A button label that translates to \"Delete\".",
"isCommentAutoGenerated" : true
},
"Favorite" : {
"comment" : "Item editor title label for marking the item as a favorite",
"extractionState" : "stale",
@@ -397,14 +411,6 @@
}
}
},
"inset %@" : {
"comment" : "A label displaying the amount by which the category tabs are inset when they are pinned to the top of the view. The argument is the string “inf”, the string “max+” or the string “min-”.",
"isCommentAutoGenerated" : true
},
"inset %lld" : {
"comment" : "A label showing the amount of space between the bottom of the category tabs and the top of the viewport.",
"isCommentAutoGenerated" : true
},
"Insira o código de 8 dígitos enviado" : {
"comment" : "A description below the text field where the user inputs their OTP code.",
"isCommentAutoGenerated" : true
@@ -536,10 +542,6 @@
},
"O que vai querer pedir hoje?\n " : {
},
"offset %@" : {
"comment" : "A label displaying the current scroll offset, useful for debugging The argument is the string “inf”, the string “max+” or the string “min-”.",
"isCommentAutoGenerated" : true
},
"para %@" : {
"comment" : "A text label displaying the email address to which the one-time password (OTP) was sent. The text is truncated to fit within one line.",
@@ -557,10 +559,6 @@
"comment" : "A label for the \"Profile\" tab in the main tab view.",
"isCommentAutoGenerated" : true
},
"pin %lld" : {
"comment" : "A label showing whether the category tabs are pinned or not. The argument is a boolean value (`true` if pinned, `false` otherwise)",
"isCommentAutoGenerated" : true
},
"Política de Privacidade" : {
"comment" : "The title of the privacy policy section.",
"isCommentAutoGenerated" : true
@@ -621,14 +619,6 @@
},
"RESTAURANT" : {
},
"safe %@" : {
"comment" : "A debug label showing the safe area inset value. The argument is the string “inf”, the string “max+” or the string “min-”.",
"isCommentAutoGenerated" : true
},
"safe %lld" : {
"comment" : "A text element displaying the safe area inset at the top of the view",
"isCommentAutoGenerated" : true
},
"Save" : {
"comment" : "Button title indicating that the current contents should be saved",
@@ -732,10 +722,6 @@
"comment" : "A link to the app's \"Terms of Use\".",
"isCommentAutoGenerated" : true
},
"th %@" : {
"comment" : "A label displaying the threshold at which the category tabs should become pinned. The argument is the string “inf”, the string “max+” or the string “min-”.",
"isCommentAutoGenerated" : true
},
"Title" : {
"comment" : "Label for the item editor form indicating the title of the item",
"extractionState" : "stale",

View File

@@ -127,18 +127,46 @@ final class ApiService {
}
func addCustomerAddress(_ address: CustomerAddress) async throws -> ApiEnvelope<CustomerProfile> {
return try await saveCustomerAddress(address, replacingAddressId: nil)
}
func saveCustomerAddress(_ address: CustomerAddress, replacingAddressId: String?) async throws -> ApiEnvelope<CustomerProfile> {
let currentProfile = try await profile()
guard currentProfile.error == false, let customer = currentProfile.result else {
throw NetworkError.invalidResponse
}
var addressBook = (customer.addressBook ?? []).map(CustomerAddressPayload.init(from:))
addressBook.insert(CustomerAddressPayload(from: address), at: 0)
let currentAddressBook = customer.addressBook ?? []
var addressBook = currentAddressBook.map(CustomerAddressPayload.init(from:))
let addressPayload = CustomerAddressPayload(from: address)
let payload = CustomerProfileUpdatePayload(addressBook: addressBook)
let body = try JSONEncoder().encode(payload)
let req = ApiRequest(path: "/api/customer/\(customer.id)", method: "POST", module: .customer, requiresAuth: true, body: body)
return try await sendEnvelope(req)
if let replacingAddressId,
let replaceIndex = currentAddressBook.firstIndex(where: { $0.id == replacingAddressId }) {
addressBook[replaceIndex] = addressPayload
} else {
addressBook.insert(addressPayload, at: 0)
}
return try await updateCustomerAddressBook(customerId: customer.id, addressBook: addressBook)
}
func deleteCustomerAddress(_ address: CustomerAddress) async throws -> ApiEnvelope<CustomerProfile> {
let currentProfile = try await profile()
guard currentProfile.error == false, let customer = currentProfile.result else {
throw NetworkError.invalidResponse
}
var currentAddressBook = customer.addressBook ?? []
if let targetId = address.id, targetId.isEmpty == false {
if let index = currentAddressBook.firstIndex(where: { $0.id == targetId }) {
currentAddressBook.remove(at: index)
}
} else if let index = currentAddressBook.firstIndex(where: { matchesAddress($0, address) }) {
currentAddressBook.remove(at: index)
}
let updatedAddressBook = currentAddressBook.map(CustomerAddressPayload.init(from:))
return try await updateCustomerAddressBook(customerId: customer.id, addressBook: updatedAddressBook)
}
func lookupZipCode(_ zipCode: String) async throws -> ApiEnvelope<CepLookupResult> {
@@ -157,6 +185,24 @@ final class ApiService {
return try await sendEnvelope(req)
}
private func updateCustomerAddressBook(customerId: String, addressBook: [CustomerAddressPayload]) async throws -> ApiEnvelope<CustomerProfile> {
let payload = CustomerProfileUpdatePayload(addressBook: addressBook)
let body = try JSONEncoder().encode(payload)
let req = ApiRequest(path: "/api/customer/\(customerId)", method: "POST", module: .customer, requiresAuth: true, body: body)
return try await sendEnvelope(req)
}
private func matchesAddress(_ lhs: CustomerAddress, _ rhs: CustomerAddress) -> Bool {
lhs.label == rhs.label &&
lhs.address == rhs.address &&
lhs.number == rhs.number &&
lhs.complement == rhs.complement &&
lhs.neighborhood == rhs.neighborhood &&
lhs.city == rhs.city &&
lhs.state == rhs.state &&
lhs.zipCode == rhs.zipCode
}
// MARK: - Stores
func listPublicCategories() async throws -> ApiEnvelope<[PublicCategory]> {

View File

@@ -1,4 +1,7 @@
import SwiftUI
#if canImport(UIKit)
import UIKit
#endif
struct ProfileView: View {
@Binding var root: RootFlow
@@ -87,6 +90,9 @@ struct AddressesView: View {
@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
@@ -128,6 +134,7 @@ struct AddressesView: View {
.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
@@ -143,7 +150,26 @@ struct AddressesView: View {
}
.buttonStyle(.plain)
} else {
AddressCard(item: addressToListItem(address, isPrimary: isSelected))
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)
}
}
}
@@ -164,22 +190,18 @@ struct AddressesView: View {
.toolbar(.hidden, for: .navigationBar)
.sheet(isPresented: $openAddAddressForm) {
NavigationStack {
AddAddressFormView { updatedAddresses in
AddAddressFormView(existingAddress: editingAddress) { updatedAddresses, wasEditing in
addresses = updatedAddresses
let first = updatedAddresses.first
appState.address.selectedId = first?.id
appState.address.display = first?.label?.isEmpty == false ? (first?.label ?? "Defina seu endereco") : "Defina seu endereco"
if let lat = first?.latLong?.first, let lng = first?.latLong?.dropFirst().first {
appState.address.latitude = lat
appState.address.longitude = lng
} else {
appState.address.latitude = nil
appState.address.longitude = nil
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)
}
SessionStateStore.saveAddress(appState.address)
SnackbarCenter.shared.show(title: "Endereço adicionado com sucesso.", 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 {
@@ -218,7 +240,10 @@ struct AddressesView: View {
.fill(AppColors.backgroundLight)
.frame(height: 136)
Button(action: { openAddAddressForm = true }) {
Button(action: {
editingAddress = nil
openAddAddressForm = true
}) {
HStack(spacing: 12) {
Image(systemName: "mappin.circle.fill")
.font(.system(size: 24))
@@ -257,6 +282,67 @@ struct AddressesView: View {
}
}
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]
@@ -351,7 +437,8 @@ struct AddAddressFormView: View {
@State var lookedUpLatitude: Double? = nil
@State var lookedUpLongitude: Double? = nil
let onSave: ([CustomerAddress]) -> Void
let existingAddress: CustomerAddress?
let onSave: ([CustomerAddress], Bool) -> Void
private var isFormValid: Bool {
!label.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty &&
@@ -383,11 +470,11 @@ struct AddAddressFormView: View {
.scaledToFit()
.frame(width: 120, height: 120)
Text("Novo endereço")
Text(existingAddress == nil ? "Novo endereço" : "Editar endereço")
.font(AppTypography.heading1)
.foregroundStyle(colorScheme == .dark ? Color.white : AppColors.textPrimary)
Text("Preencha os dados abaixo para adicionar um endereço.")
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)
@@ -440,7 +527,7 @@ struct AddAddressFormView: View {
.padding(.horizontal, 24)
.padding(.bottom, 20)
PrimaryButton(title: "Salvar endereço", image: Image(systemName: "checkmark")) {
PrimaryButton(title: existingAddress == nil ? "Salvar endereço" : "Atualizar endereço", image: Image(systemName: "checkmark")) {
saveAddress()
}
.padding(.horizontal, 24)
@@ -461,6 +548,9 @@ struct AddAddressFormView: View {
}
.navigationBarBackButtonHidden(true)
.toolbar(.hidden, for: .navigationBar)
.onAppear {
populateFromExistingAddressIfNeeded()
}
}
private func saveAddress() {
@@ -473,7 +563,7 @@ struct AddAddressFormView: View {
}()
let newAddress = CustomerAddress(
id: UUID().uuidString,
id: existingAddress?.id ?? UUID().uuidString,
label: clean(label),
address: clean(address),
number: clean(number),
@@ -489,7 +579,7 @@ struct AddAddressFormView: View {
Task {
do {
let response = try await ApiService().addCustomerAddress(newAddress)
let response = try await ApiService().saveCustomerAddress(newAddress, replacingAddressId: existingAddress?.id)
await MainActor.run {
isLoading = false
if response.error {
@@ -499,7 +589,7 @@ struct AddAddressFormView: View {
}
let updatedAddresses = response.result?.addressBook ?? [newAddress]
onSave(updatedAddresses)
onSave(updatedAddresses, existingAddress != nil)
dismiss()
}
} catch {
@@ -513,6 +603,21 @@ struct AddAddressFormView: View {
}
}
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)
}
@@ -579,8 +684,106 @@ 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) {
@@ -591,16 +794,20 @@ struct AddressCard: View {
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)
.padding(.horizontal, 10)
.padding(.vertical, 6)
.lineLimit(1)
.fixedSize(horizontal: true, vertical: false)
.padding(.horizontal, 9)
.padding(.vertical, 5)
.background(AppColors.tertiary)
.clipShape(Capsule())
.lineLimit(1)
}
}
@@ -612,24 +819,30 @@ struct AddressCard: View {
Spacer(minLength: 8)
if onEdit != nil || onDelete != nil {
Rectangle()
.fill(AppColors.backgroundLight)
.frame(width: 1, height: 96)
}
VStack(spacing: 24) {
Button(action: {}) {
if let onEdit {
Button(action: onEdit) {
Image(systemName: "pencil")
.font(.system(size: 22))
.foregroundStyle(AppColors.textMuted)
}
}
Button(action: {}) {
if let onDelete {
Button(action: onDelete) {
Image(systemName: "trash")
.font(.system(size: 22))
.foregroundStyle(AppColors.textMuted)
}
}
.frame(width: 40)
}
.frame(width: onEdit != nil || onDelete != nil ? 40 : 0)
}
.padding(.horizontal, 16)
.padding(.vertical, 20)

View File

@@ -40,15 +40,12 @@ struct StoreDetailView: View {
AppColors.backgroundLight.ignoresSafeArea()
ScrollView(showsIndicators: false) {
LazyVStack(spacing: 0) {
LazyVStack(spacing: 0, pinnedViews: [.sectionHeaders]) {
topSection
categoryTabs(proxy: proxy, safeTop: safeTop, isSticky: false)
sectionedProducts
.padding(.horizontal, 16)
.padding(.top, 12)
.padding(.bottom, 120)
}
}
.ignoresSafeArea(edges: .top)
@@ -62,23 +59,6 @@ struct StoreDetailView: View {
.zIndex(20)
}
}
.overlay(alignment: .topTrailing) {
VStack(alignment: .trailing, spacing: 2) {
Text("safe \(debugNumber(safeTop))")
Text("offset \(debugNumber(scrollOffsetY))")
Text("th \(debugNumber(categoryTabsPinThreshold(safeTop: safeTop)))")
Text("pin \(isCategoryTabsPinned(safeTop: safeTop) ? 1 : 0)")
Text("inset \(debugNumber(categoryTabsPinnedInset(safeTop: safeTop)))")
}
.font(.system(size: 11, weight: .semibold, design: .monospaced))
.foregroundStyle(.white)
.padding(8)
.background(Color.black.opacity(0.7))
.clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
.padding(.top, safeTop + 6)
.padding(.trailing, 8)
.allowsHitTesting(false)
}
}
}
.navigationBarBackButtonHidden(true)
@@ -163,7 +143,7 @@ struct StoreDetailView: View {
.frame(width: storeLogoSize, height: storeLogoSize)
.overlay(
Circle()
.stroke(Color.white, lineWidth: 5)
.stroke(Color.white, lineWidth: 0.1)
)
AsyncStoreImage(imageURL: resolvedURL(storeLogoURL))
@@ -231,14 +211,28 @@ struct StoreDetailView: View {
.font(AppTypography.body)
.foregroundStyle(AppColors.textMuted)
.padding(.top, 16)
.padding(.horizontal, 16)
.padding(.bottom, 120)
} else {
VStack(alignment: .leading, spacing: 26) {
ForEach(categories, id: \.id) { category in
VStack(alignment: .leading, spacing: 12) {
Section {
VStack(spacing: 12) {
ForEach(category.products) { product in
productCard(product)
}
}
.padding(.horizontal, 16)
.padding(.bottom, 8)
} header: {
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(
@@ -247,15 +241,9 @@ struct StoreDetailView: View {
)
}
)
VStack(spacing: 12) {
ForEach(category.products) { product in
productCard(product)
}
}
}
}
}
Color.clear.frame(height: 120)
}
}
@@ -304,19 +292,6 @@ struct StoreDetailView: View {
max(8, safeTop - 44)
}
private func debugNumber(_ value: CGFloat) -> String {
if value.isFinite == false {
return "inf"
}
if value > CGFloat(Int.max) {
return "max+"
}
if value < CGFloat(Int.min) {
return "min-"
}
return String(Int(value.rounded()))
}
private func productCard(_ product: StoreCatalogProduct) -> some View {
HStack(spacing: 12) {
VStack(alignment: .leading, spacing: 8) {