swipe to delete address
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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]> {
|
||||
|
||||
@@ -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,23 +190,19 @@ 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
|
||||
}
|
||||
SessionStateStore.saveAddress(appState.address)
|
||||
SnackbarCenter.shared.show(title: "Endereço adicionado com sucesso.", style: .success, icon: "checkmark.seal.fill", duration: 3.0)
|
||||
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 {
|
||||
@@ -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)
|
||||
|
||||
Rectangle()
|
||||
.fill(AppColors.backgroundLight)
|
||||
.frame(width: 1, height: 96)
|
||||
if onEdit != nil || onDelete != nil {
|
||||
Rectangle()
|
||||
.fill(AppColors.backgroundLight)
|
||||
.frame(width: 1, height: 96)
|
||||
}
|
||||
|
||||
VStack(spacing: 24) {
|
||||
Button(action: {}) {
|
||||
Image(systemName: "pencil")
|
||||
.font(.system(size: 22))
|
||||
.foregroundStyle(AppColors.textMuted)
|
||||
if let onEdit {
|
||||
Button(action: onEdit) {
|
||||
Image(systemName: "pencil")
|
||||
.font(.system(size: 22))
|
||||
.foregroundStyle(AppColors.textMuted)
|
||||
}
|
||||
}
|
||||
|
||||
Button(action: {}) {
|
||||
Image(systemName: "trash")
|
||||
.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: 40)
|
||||
.frame(width: onEdit != nil || onDelete != nil ? 40 : 0)
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 20)
|
||||
|
||||
@@ -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)
|
||||
@@ -58,27 +55,10 @@ struct StoreDetailView: View {
|
||||
.overlay(alignment: .top) {
|
||||
if isCategoryTabsPinned(safeTop: safeTop) {
|
||||
categoryTabs(proxy: proxy, safeTop: safeTop, isSticky: true)
|
||||
.transition(.opacity)
|
||||
.zIndex(20)
|
||||
.transition(.opacity)
|
||||
.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,31 +211,39 @@ 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) {
|
||||
Text(category.name)
|
||||
.font(AppTypography.heading2)
|
||||
.foregroundStyle(AppColors.textPrimary)
|
||||
.id(sectionAnchorId(for: category.id))
|
||||
.background(
|
||||
GeometryReader { geometry in
|
||||
Color.clear.preference(
|
||||
key: CategoryHeaderOffsetPreferenceKey.self,
|
||||
value: [category.id: geometry.frame(in: .global).minY]
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
VStack(spacing: 12) {
|
||||
ForEach(category.products) { product in
|
||||
productCard(product)
|
||||
}
|
||||
ForEach(categories, id: \.id) { category in
|
||||
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(
|
||||
key: CategoryHeaderOffsetPreferenceKey.self,
|
||||
value: [category.id: geometry.frame(in: .global).minY]
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
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) {
|
||||
|
||||
Reference in New Issue
Block a user