From 4a40936293c761e29ee09de064231185c27c9b97 Mon Sep 17 00:00:00 2001 From: Daniel Arantes Loverde Date: Thu, 12 Feb 2026 18:39:02 -0300 Subject: [PATCH] swipe to delete address --- .../PediFoods/Resources/Localizable.xcstrings | 42 +-- .../PediFoods/Services/ApiService.swift | 58 +++- .../PediFoods/Views/Main/ProfileView.swift | 287 +++++++++++++++--- .../Views/Main/StoreDetailView.swift | 89 ++---- 4 files changed, 348 insertions(+), 128 deletions(-) diff --git a/pedi-foods/Sources/PediFoods/Resources/Localizable.xcstrings b/pedi-foods/Sources/PediFoods/Resources/Localizable.xcstrings index 9bce5b8..9ba309c 100644 --- a/pedi-foods/Sources/PediFoods/Resources/Localizable.xcstrings +++ b/pedi-foods/Sources/PediFoods/Resources/Localizable.xcstrings @@ -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", diff --git a/pedi-foods/Sources/PediFoods/Services/ApiService.swift b/pedi-foods/Sources/PediFoods/Services/ApiService.swift index 1da5798..ad59d35 100644 --- a/pedi-foods/Sources/PediFoods/Services/ApiService.swift +++ b/pedi-foods/Sources/PediFoods/Services/ApiService.swift @@ -127,18 +127,46 @@ final class ApiService { } func addCustomerAddress(_ address: CustomerAddress) async throws -> ApiEnvelope { + return try await saveCustomerAddress(address, replacingAddressId: nil) + } + + func saveCustomerAddress(_ address: CustomerAddress, replacingAddressId: String?) async throws -> ApiEnvelope { 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 { + 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 { @@ -157,6 +185,24 @@ final class ApiService { return try await sendEnvelope(req) } + private func updateCustomerAddressBook(customerId: String, addressBook: [CustomerAddressPayload]) async throws -> ApiEnvelope { + 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]> { diff --git a/pedi-foods/Sources/PediFoods/Views/Main/ProfileView.swift b/pedi-foods/Sources/PediFoods/Views/Main/ProfileView.swift index 542bbf6..11a404a 100644 --- a/pedi-foods/Sources/PediFoods/Views/Main/ProfileView.swift +++ b/pedi-foods/Sources/PediFoods/Views/Main/ProfileView.swift @@ -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: 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) diff --git a/pedi-foods/Sources/PediFoods/Views/Main/StoreDetailView.swift b/pedi-foods/Sources/PediFoods/Views/Main/StoreDetailView.swift index 4846a5b..1efd34e 100644 --- a/pedi-foods/Sources/PediFoods/Views/Main/StoreDetailView.swift +++ b/pedi-foods/Sources/PediFoods/Views/Main/StoreDetailView.swift @@ -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) {