swipe to delete address
This commit is contained in:
@@ -3,6 +3,9 @@
|
|||||||
"strings" : {
|
"strings" : {
|
||||||
"" : {
|
"" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"..." : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"·" : {
|
"·" : {
|
||||||
"comment" : "A period character used to separate different pieces of information in a list.",
|
"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!" : {
|
"Boas-vindas!" : {
|
||||||
"comment" : "A welcome message displayed in the login view.",
|
"comment" : "A welcome message displayed in the login view.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
@@ -279,6 +286,9 @@
|
|||||||
"DL" : {
|
"DL" : {
|
||||||
"comment" : "An abbreviation for \"Delivery Lady\" used in the user's profile picture circle.",
|
"comment" : "An abbreviation for \"Delivery Lady\" used in the user's profile picture circle.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
|
"Editar endereço" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Enderecos" : {
|
"Enderecos" : {
|
||||||
"comment" : "A link to the user's address list.",
|
"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.",
|
"comment" : "A description below the login fields, explaining that a verification code will be sent via SMS or email to confirm access.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
|
"Excluir" : {
|
||||||
|
"comment" : "A button label that translates to \"Delete\".",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Favorite" : {
|
"Favorite" : {
|
||||||
"comment" : "Item editor title label for marking the item as a favorite",
|
"comment" : "Item editor title label for marking the item as a favorite",
|
||||||
"extractionState" : "stale",
|
"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" : {
|
"Insira o código de 8 dígitos enviado" : {
|
||||||
"comment" : "A description below the text field where the user inputs their OTP code.",
|
"comment" : "A description below the text field where the user inputs their OTP code.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
@@ -536,10 +542,6 @@
|
|||||||
},
|
},
|
||||||
"O que vai querer pedir hoje?\n " : {
|
"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 %@" : {
|
"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.",
|
"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.",
|
"comment" : "A label for the \"Profile\" tab in the main tab view.",
|
||||||
"isCommentAutoGenerated" : true
|
"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" : {
|
"Política de Privacidade" : {
|
||||||
"comment" : "The title of the privacy policy section.",
|
"comment" : "The title of the privacy policy section.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
@@ -621,14 +619,6 @@
|
|||||||
},
|
},
|
||||||
"RESTAURANT" : {
|
"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" : {
|
"Save" : {
|
||||||
"comment" : "Button title indicating that the current contents should be saved",
|
"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\".",
|
"comment" : "A link to the app's \"Terms of Use\".",
|
||||||
"isCommentAutoGenerated" : true
|
"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" : {
|
"Title" : {
|
||||||
"comment" : "Label for the item editor form indicating the title of the item",
|
"comment" : "Label for the item editor form indicating the title of the item",
|
||||||
"extractionState" : "stale",
|
"extractionState" : "stale",
|
||||||
|
|||||||
@@ -127,18 +127,46 @@ final class ApiService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func addCustomerAddress(_ address: CustomerAddress) async throws -> ApiEnvelope<CustomerProfile> {
|
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()
|
let currentProfile = try await profile()
|
||||||
guard currentProfile.error == false, let customer = currentProfile.result else {
|
guard currentProfile.error == false, let customer = currentProfile.result else {
|
||||||
throw NetworkError.invalidResponse
|
throw NetworkError.invalidResponse
|
||||||
}
|
}
|
||||||
|
|
||||||
var addressBook = (customer.addressBook ?? []).map(CustomerAddressPayload.init(from:))
|
let currentAddressBook = customer.addressBook ?? []
|
||||||
addressBook.insert(CustomerAddressPayload(from: address), at: 0)
|
var addressBook = currentAddressBook.map(CustomerAddressPayload.init(from:))
|
||||||
|
let addressPayload = CustomerAddressPayload(from: address)
|
||||||
|
|
||||||
let payload = CustomerProfileUpdatePayload(addressBook: addressBook)
|
if let replacingAddressId,
|
||||||
let body = try JSONEncoder().encode(payload)
|
let replaceIndex = currentAddressBook.firstIndex(where: { $0.id == replacingAddressId }) {
|
||||||
let req = ApiRequest(path: "/api/customer/\(customer.id)", method: "POST", module: .customer, requiresAuth: true, body: body)
|
addressBook[replaceIndex] = addressPayload
|
||||||
return try await sendEnvelope(req)
|
} 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> {
|
func lookupZipCode(_ zipCode: String) async throws -> ApiEnvelope<CepLookupResult> {
|
||||||
@@ -157,6 +185,24 @@ final class ApiService {
|
|||||||
return try await sendEnvelope(req)
|
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
|
// MARK: - Stores
|
||||||
|
|
||||||
func listPublicCategories() async throws -> ApiEnvelope<[PublicCategory]> {
|
func listPublicCategories() async throws -> ApiEnvelope<[PublicCategory]> {
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
#if canImport(UIKit)
|
||||||
|
import UIKit
|
||||||
|
#endif
|
||||||
|
|
||||||
struct ProfileView: View {
|
struct ProfileView: View {
|
||||||
@Binding var root: RootFlow
|
@Binding var root: RootFlow
|
||||||
@@ -87,6 +90,9 @@ struct AddressesView: View {
|
|||||||
@State var errorMessage: String? = nil
|
@State var errorMessage: String? = nil
|
||||||
@State var addresses: [CustomerAddress] = []
|
@State var addresses: [CustomerAddress] = []
|
||||||
@State var openAddAddressForm = false
|
@State var openAddAddressForm = false
|
||||||
|
@State var editingAddress: CustomerAddress? = nil
|
||||||
|
@State var openSwipeRowId: String? = nil
|
||||||
|
@State var deletingRowId: String? = nil
|
||||||
|
|
||||||
let tabBarClearance: CGFloat = 96
|
let tabBarClearance: CGFloat = 96
|
||||||
|
|
||||||
@@ -128,6 +134,7 @@ struct AddressesView: View {
|
|||||||
.padding(.top, 24)
|
.padding(.top, 24)
|
||||||
} else {
|
} else {
|
||||||
ForEach(Array(addresses.enumerated()), id: \.offset) { index, address in
|
ForEach(Array(addresses.enumerated()), id: \.offset) { index, address in
|
||||||
|
let rowId = addressRowId(for: address, index: index)
|
||||||
let isSelected: Bool = {
|
let isSelected: Bool = {
|
||||||
if let selectedId = appState.address.selectedId {
|
if let selectedId = appState.address.selectedId {
|
||||||
return address.id == selectedId
|
return address.id == selectedId
|
||||||
@@ -143,7 +150,26 @@ struct AddressesView: View {
|
|||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
} else {
|
} 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)
|
.toolbar(.hidden, for: .navigationBar)
|
||||||
.sheet(isPresented: $openAddAddressForm) {
|
.sheet(isPresented: $openAddAddressForm) {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
AddAddressFormView { updatedAddresses in
|
AddAddressFormView(existingAddress: editingAddress) { updatedAddresses, wasEditing in
|
||||||
addresses = updatedAddresses
|
addresses = updatedAddresses
|
||||||
let first = updatedAddresses.first
|
applyPreferredAddress(from: updatedAddresses)
|
||||||
appState.address.selectedId = first?.id
|
let title = wasEditing ? "Endereço atualizado com sucesso." : "Endereço adicionado com sucesso."
|
||||||
appState.address.display = first?.label?.isEmpty == false ? (first?.label ?? "Defina seu endereco") : "Defina seu endereco"
|
SnackbarCenter.shared.show(title: title, style: .success, icon: "checkmark.seal.fill", duration: 3.0)
|
||||||
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
|
|
||||||
}
|
}
|
||||||
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 {
|
.onAppear {
|
||||||
if isLoading == false, addresses.isEmpty {
|
if isLoading == false, addresses.isEmpty {
|
||||||
@@ -218,7 +240,10 @@ struct AddressesView: View {
|
|||||||
.fill(AppColors.backgroundLight)
|
.fill(AppColors.backgroundLight)
|
||||||
.frame(height: 136)
|
.frame(height: 136)
|
||||||
|
|
||||||
Button(action: { openAddAddressForm = true }) {
|
Button(action: {
|
||||||
|
editingAddress = nil
|
||||||
|
openAddAddressForm = true
|
||||||
|
}) {
|
||||||
HStack(spacing: 12) {
|
HStack(spacing: 12) {
|
||||||
Image(systemName: "mappin.circle.fill")
|
Image(systemName: "mappin.circle.fill")
|
||||||
.font(.system(size: 24))
|
.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 {
|
func addressToListItem(_ address: CustomerAddress, isPrimary: Bool) -> AddressListItem {
|
||||||
let title = address.label?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false ? (address.label ?? "Endereço") : "Endereço"
|
let title = address.label?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false ? (address.label ?? "Endereço") : "Endereço"
|
||||||
let line1 = [address.address, address.number]
|
let line1 = [address.address, address.number]
|
||||||
@@ -351,7 +437,8 @@ struct AddAddressFormView: View {
|
|||||||
@State var lookedUpLatitude: Double? = nil
|
@State var lookedUpLatitude: Double? = nil
|
||||||
@State var lookedUpLongitude: Double? = nil
|
@State var lookedUpLongitude: Double? = nil
|
||||||
|
|
||||||
let onSave: ([CustomerAddress]) -> Void
|
let existingAddress: CustomerAddress?
|
||||||
|
let onSave: ([CustomerAddress], Bool) -> Void
|
||||||
|
|
||||||
private var isFormValid: Bool {
|
private var isFormValid: Bool {
|
||||||
!label.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty &&
|
!label.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty &&
|
||||||
@@ -383,11 +470,11 @@ struct AddAddressFormView: View {
|
|||||||
.scaledToFit()
|
.scaledToFit()
|
||||||
.frame(width: 120, height: 120)
|
.frame(width: 120, height: 120)
|
||||||
|
|
||||||
Text("Novo endereço")
|
Text(existingAddress == nil ? "Novo endereço" : "Editar endereço")
|
||||||
.font(AppTypography.heading1)
|
.font(AppTypography.heading1)
|
||||||
.foregroundStyle(colorScheme == .dark ? Color.white : AppColors.textPrimary)
|
.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)
|
.font(AppTypography.body)
|
||||||
.foregroundStyle(colorScheme == .dark ? Color.white : AppColors.textPrimary)
|
.foregroundStyle(colorScheme == .dark ? Color.white : AppColors.textPrimary)
|
||||||
.multilineTextAlignment(.center)
|
.multilineTextAlignment(.center)
|
||||||
@@ -440,7 +527,7 @@ struct AddAddressFormView: View {
|
|||||||
.padding(.horizontal, 24)
|
.padding(.horizontal, 24)
|
||||||
.padding(.bottom, 20)
|
.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()
|
saveAddress()
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 24)
|
.padding(.horizontal, 24)
|
||||||
@@ -461,6 +548,9 @@ struct AddAddressFormView: View {
|
|||||||
}
|
}
|
||||||
.navigationBarBackButtonHidden(true)
|
.navigationBarBackButtonHidden(true)
|
||||||
.toolbar(.hidden, for: .navigationBar)
|
.toolbar(.hidden, for: .navigationBar)
|
||||||
|
.onAppear {
|
||||||
|
populateFromExistingAddressIfNeeded()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func saveAddress() {
|
private func saveAddress() {
|
||||||
@@ -473,7 +563,7 @@ struct AddAddressFormView: View {
|
|||||||
}()
|
}()
|
||||||
|
|
||||||
let newAddress = CustomerAddress(
|
let newAddress = CustomerAddress(
|
||||||
id: UUID().uuidString,
|
id: existingAddress?.id ?? UUID().uuidString,
|
||||||
label: clean(label),
|
label: clean(label),
|
||||||
address: clean(address),
|
address: clean(address),
|
||||||
number: clean(number),
|
number: clean(number),
|
||||||
@@ -489,7 +579,7 @@ struct AddAddressFormView: View {
|
|||||||
|
|
||||||
Task {
|
Task {
|
||||||
do {
|
do {
|
||||||
let response = try await ApiService().addCustomerAddress(newAddress)
|
let response = try await ApiService().saveCustomerAddress(newAddress, replacingAddressId: existingAddress?.id)
|
||||||
await MainActor.run {
|
await MainActor.run {
|
||||||
isLoading = false
|
isLoading = false
|
||||||
if response.error {
|
if response.error {
|
||||||
@@ -499,7 +589,7 @@ struct AddAddressFormView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let updatedAddresses = response.result?.addressBook ?? [newAddress]
|
let updatedAddresses = response.result?.addressBook ?? [newAddress]
|
||||||
onSave(updatedAddresses)
|
onSave(updatedAddresses, existingAddress != nil)
|
||||||
dismiss()
|
dismiss()
|
||||||
}
|
}
|
||||||
} catch {
|
} 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 {
|
private func clean(_ value: String) -> String {
|
||||||
value.trimmingCharacters(in: .whitespacesAndNewlines)
|
value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
}
|
}
|
||||||
@@ -579,8 +684,106 @@ func normalizeZipCodeForAPI(_ input: String) -> String {
|
|||||||
String(input.filter(\.isNumber).prefix(8))
|
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 {
|
struct AddressCard: View {
|
||||||
let item: AddressListItem
|
let item: AddressListItem
|
||||||
|
var onEdit: (() -> Void)? = nil
|
||||||
|
var onDelete: (() -> Void)? = nil
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
HStack(spacing: 14) {
|
HStack(spacing: 14) {
|
||||||
@@ -591,16 +794,20 @@ struct AddressCard: View {
|
|||||||
Text(item.title)
|
Text(item.title)
|
||||||
.font(AppTypography.heading2)
|
.font(AppTypography.heading2)
|
||||||
.foregroundStyle(AppColors.textPrimary)
|
.foregroundStyle(AppColors.textPrimary)
|
||||||
|
.lineLimit(1)
|
||||||
|
.truncationMode(.tail)
|
||||||
|
.minimumScaleFactor(0.9)
|
||||||
|
|
||||||
if item.isPrimary {
|
if item.isPrimary {
|
||||||
Text("PRINCIPAL")
|
Text("PRINCIPAL")
|
||||||
.font(AppTypography.overline)
|
.font(AppTypography.overline)
|
||||||
.foregroundStyle(AppColors.textPrimary)
|
.foregroundStyle(AppColors.textPrimary)
|
||||||
.padding(.horizontal, 10)
|
.lineLimit(1)
|
||||||
.padding(.vertical, 6)
|
.fixedSize(horizontal: true, vertical: false)
|
||||||
|
.padding(.horizontal, 9)
|
||||||
|
.padding(.vertical, 5)
|
||||||
.background(AppColors.tertiary)
|
.background(AppColors.tertiary)
|
||||||
.clipShape(Capsule())
|
.clipShape(Capsule())
|
||||||
.lineLimit(1)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -612,24 +819,30 @@ struct AddressCard: View {
|
|||||||
|
|
||||||
Spacer(minLength: 8)
|
Spacer(minLength: 8)
|
||||||
|
|
||||||
|
if onEdit != nil || onDelete != nil {
|
||||||
Rectangle()
|
Rectangle()
|
||||||
.fill(AppColors.backgroundLight)
|
.fill(AppColors.backgroundLight)
|
||||||
.frame(width: 1, height: 96)
|
.frame(width: 1, height: 96)
|
||||||
|
}
|
||||||
|
|
||||||
VStack(spacing: 24) {
|
VStack(spacing: 24) {
|
||||||
Button(action: {}) {
|
if let onEdit {
|
||||||
|
Button(action: onEdit) {
|
||||||
Image(systemName: "pencil")
|
Image(systemName: "pencil")
|
||||||
.font(.system(size: 22))
|
.font(.system(size: 22))
|
||||||
.foregroundStyle(AppColors.textMuted)
|
.foregroundStyle(AppColors.textMuted)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Button(action: {}) {
|
if let onDelete {
|
||||||
|
Button(action: onDelete) {
|
||||||
Image(systemName: "trash")
|
Image(systemName: "trash")
|
||||||
.font(.system(size: 22))
|
.font(.system(size: 22))
|
||||||
.foregroundStyle(AppColors.textMuted)
|
.foregroundStyle(AppColors.textMuted)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.frame(width: 40)
|
}
|
||||||
|
.frame(width: onEdit != nil || onDelete != nil ? 40 : 0)
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 16)
|
.padding(.horizontal, 16)
|
||||||
.padding(.vertical, 20)
|
.padding(.vertical, 20)
|
||||||
|
|||||||
@@ -40,15 +40,12 @@ struct StoreDetailView: View {
|
|||||||
AppColors.backgroundLight.ignoresSafeArea()
|
AppColors.backgroundLight.ignoresSafeArea()
|
||||||
|
|
||||||
ScrollView(showsIndicators: false) {
|
ScrollView(showsIndicators: false) {
|
||||||
LazyVStack(spacing: 0) {
|
LazyVStack(spacing: 0, pinnedViews: [.sectionHeaders]) {
|
||||||
topSection
|
topSection
|
||||||
|
|
||||||
categoryTabs(proxy: proxy, safeTop: safeTop, isSticky: false)
|
categoryTabs(proxy: proxy, safeTop: safeTop, isSticky: false)
|
||||||
|
|
||||||
sectionedProducts
|
sectionedProducts
|
||||||
.padding(.horizontal, 16)
|
|
||||||
.padding(.top, 12)
|
|
||||||
.padding(.bottom, 120)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.ignoresSafeArea(edges: .top)
|
.ignoresSafeArea(edges: .top)
|
||||||
@@ -62,23 +59,6 @@ struct StoreDetailView: View {
|
|||||||
.zIndex(20)
|
.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)
|
.navigationBarBackButtonHidden(true)
|
||||||
@@ -163,7 +143,7 @@ struct StoreDetailView: View {
|
|||||||
.frame(width: storeLogoSize, height: storeLogoSize)
|
.frame(width: storeLogoSize, height: storeLogoSize)
|
||||||
.overlay(
|
.overlay(
|
||||||
Circle()
|
Circle()
|
||||||
.stroke(Color.white, lineWidth: 5)
|
.stroke(Color.white, lineWidth: 0.1)
|
||||||
)
|
)
|
||||||
|
|
||||||
AsyncStoreImage(imageURL: resolvedURL(storeLogoURL))
|
AsyncStoreImage(imageURL: resolvedURL(storeLogoURL))
|
||||||
@@ -231,14 +211,28 @@ struct StoreDetailView: View {
|
|||||||
.font(AppTypography.body)
|
.font(AppTypography.body)
|
||||||
.foregroundStyle(AppColors.textMuted)
|
.foregroundStyle(AppColors.textMuted)
|
||||||
.padding(.top, 16)
|
.padding(.top, 16)
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.bottom, 120)
|
||||||
} else {
|
} else {
|
||||||
VStack(alignment: .leading, spacing: 26) {
|
|
||||||
ForEach(categories, id: \.id) { category in
|
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)
|
Text(category.name)
|
||||||
.font(AppTypography.heading2)
|
.font(AppTypography.heading2)
|
||||||
.foregroundStyle(AppColors.textPrimary)
|
.foregroundStyle(AppColors.textPrimary)
|
||||||
.id(sectionAnchorId(for: category.id))
|
.id(sectionAnchorId(for: category.id))
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.top, 8)
|
||||||
|
.padding(.bottom, 8)
|
||||||
|
.background(AppColors.backgroundLight)
|
||||||
.background(
|
.background(
|
||||||
GeometryReader { geometry in
|
GeometryReader { geometry in
|
||||||
Color.clear.preference(
|
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)
|
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 {
|
private func productCard(_ product: StoreCatalogProduct) -> some View {
|
||||||
HStack(spacing: 12) {
|
HStack(spacing: 12) {
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
|||||||
Reference in New Issue
Block a user