import SwiftUI #if canImport(UIKit) import UIKit #endif struct ProfileView: View { @Binding var root: RootFlow @Binding var selectedTab: MainTab let tokenStore: TokenStore @Binding var appState: AppState @State var openAddressesOnboarding = false @State var onboardingMessage: String? = nil var body: some View { VStack(spacing: 20) { header VStack(spacing: 12) { NavigationLink("Pedidos") { OrdersView() } NavigationLink("Enderecos") { AddressesView(message: nil, appState: $appState) } NavigationLink("Ajuda") { Text("Ajuda") } } .font(AppTypography.body) .foregroundStyle(AppColors.textPrimary) Spacer() SecondaryButton(title: "Sair") { tokenStore.clear() SessionStateStore.clearActiveUser() appState = AppState() root = .auth } .padding(.horizontal, 20) } .padding(.top, 24) .frame(maxWidth: .infinity, maxHeight: .infinity) .background(AppColors.backgroundLight) .onAppear { guard let message = appState.address.onboardingMessage else { return } onboardingMessage = message appState.address.onboardingMessage = nil openAddressesOnboarding = true } .sheet(isPresented: $openAddressesOnboarding) { NavigationStack { AddressesView(message: onboardingMessage, appState: $appState) } } } private var header: some View { VStack(spacing: 8) { Circle() .fill(AppColors.primary) .frame(width: 72, height: 72) .overlay( Text("DL") .font(.headline) .foregroundStyle(AppColors.textInverse) ) Text(appState.profile.name.isEmpty ? "Daniel Loverde" : appState.profile.name) .font(AppTypography.heading2) .foregroundStyle(AppColors.textPrimary) Text(appState.profile.email.isEmpty ? "daniel@pedifoods.com.br" : appState.profile.email) .font(.caption) .foregroundStyle(AppColors.secondary) } } } struct AddressesView: View { let message: String? @Binding var appState: AppState var selectionMode: Bool = false @Environment(\.dismiss) var dismiss @State var isLoading = false @State var errorMessage: String? = nil @State var addresses: [CustomerAddress] = [] @State var openAddAddressForm = false @State var editingAddress: CustomerAddress? = nil @State var openSwipeRowId: String? = nil @State var deletingRowId: String? = nil let tabBarClearance: CGFloat = 96 var body: some View { ZStack { AppColors.backgroundLight .ignoresSafeArea() ScrollView(showsIndicators: false) { VStack(spacing: 20) { header if let message { Text(message) .font(AppTypography.body) .foregroundStyle(AppColors.textPrimary) .multilineTextAlignment(.center) .padding(.horizontal, 20) .padding(.vertical, 14) .frame(maxWidth: .infinity, alignment: .center) .background(AppColors.brandSoft) .clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous)) } VStack(spacing: 16) { if isLoading { ProgressView() .padding(.top, 24) } else if let errorMessage { Text(errorMessage) .font(AppTypography.body) .foregroundStyle(AppColors.textMuted) .multilineTextAlignment(.center) .padding(.top, 24) } else if addresses.isEmpty { Text("Nenhum endereço cadastrado") .font(AppTypography.body) .foregroundStyle(AppColors.textMuted) .padding(.top, 24) } else { ForEach(Array(addresses.enumerated()), id: \.offset) { index, address in let rowId = addressRowId(for: address, index: index) let isSelected: Bool = { if let selectedId = appState.address.selectedId { return address.id == selectedId } return index == 0 }() if selectionMode { Button { selectAddress(address) } label: { AddressCard(item: addressToListItem(address, isPrimary: isSelected)) } .buttonStyle(.plain) } else { 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) } } } } } .padding(.horizontal, 20) .padding(.top, 18) } VStack { Spacer() bottomOverlay .padding(.bottom, tabBarClearance) } } .navigationBarBackButtonHidden(true) .toolbar(.hidden, for: .navigationBar) .sheet(isPresented: $openAddAddressForm) { NavigationStack { AddAddressFormView(existingAddress: editingAddress) { updatedAddresses, wasEditing in addresses = updatedAddresses applyPreferredAddress(from: updatedAddresses) let title = wasEditing ? "Endereço atualizado com sucesso." : "Endereço adicionado com sucesso." SnackbarCenter.shared.show(title: title, style: .success, icon: "checkmark.seal.fill", duration: 3.0) } } } .onChange(of: openAddAddressForm) { _, isOpen in if isOpen == false { editingAddress = nil } } .onAppear { if isLoading == false, addresses.isEmpty { Task { await loadAddresses() } } } } var header: some View { ZStack { Text("Meus Endereços") .font(AppTypography.heading2) .foregroundStyle(AppColors.textPrimary) HStack { Button(action: { dismiss() }) { Image(systemName: "chevron.left") .font(.system(size: 24, weight: .semibold)) .foregroundStyle(AppColors.textPrimary) .frame(width: 52, height: 52) .background(AppColors.surface) .clipShape(Circle()) .shadow(color: .black.opacity(0.06), radius: 8, y: 2) } Spacer() } } } var bottomOverlay: some View { ZStack(alignment: .bottom) { Rectangle() .fill(AppColors.backgroundLight) .frame(height: 136) Button(action: { editingAddress = nil openAddAddressForm = true }) { HStack(spacing: 12) { Image(systemName: "mappin.circle.fill") .font(.system(size: 24)) Text("Adicionar novo endereço") .font(AppTypography.heading3) } .foregroundStyle(AppColors.textInverse) .frame(maxWidth: .infinity) .padding(.vertical, 18) .background(AppColors.primary) .clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusXL, style: .continuous)) } .buttonStyle(.plain) .padding(.horizontal, 20) .padding(.bottom, 14) } } private func selectAddress(_ address: CustomerAddress) { appState.address.selectedId = address.id let label = (address.label ?? "").trimmingCharacters(in: .whitespacesAndNewlines) appState.address.display = label.isEmpty ? "Defina seu endereco" : label if let lat = address.latLong?.first, let lng = address.latLong?.dropFirst().first { appState.address.latitude = lat appState.address.longitude = lng } else { appState.address.latitude = nil appState.address.longitude = nil } SessionStateStore.saveAddress(appState.address) if selectionMode { dismiss() } } private func beginEditing(_ address: CustomerAddress) { openSwipeRowId = nil editingAddress = address openAddAddressForm = true } private func applyPreferredAddress(from updatedAddresses: [CustomerAddress]) { let selected = 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] .compactMap { $0?.trimmingCharacters(in: .whitespacesAndNewlines) } .filter { $0.isEmpty == false } .joined(separator: ", ") let line2 = [address.neighborhood, address.city, address.state] .compactMap { $0?.trimmingCharacters(in: .whitespacesAndNewlines) } .filter { $0.isEmpty == false } .joined(separator: ", ") let detail = [line1, line2] .filter { $0.isEmpty == false } .joined(separator: " - ") return AddressListItem( title: title, detail: detail.isEmpty ? "Endereço sem detalhes" : detail, icon: iconName(for: title), isPrimary: isPrimary ) } func iconName(for label: String) -> String { let normalized = label.folding(options: [.diacriticInsensitive, .caseInsensitive], locale: .current) if normalized.contains("casa") { return "house.fill" } if normalized.contains("trabalho") { return "briefcase.fill" } return "mappin.and.ellipse" } @MainActor func loadAddresses() async { isLoading = true errorMessage = nil do { let service = ApiService() let response = try await service.profile() guard response.error == false else { errorMessage = response.message ?? "Não foi possível carregar os endereços." isLoading = false return } if let customer = response.result { appState.profile.id = customer.id appState.profile.name = customer.name appState.profile.email = customer.email appState.profile.phone = customer.phoneNumber ?? appState.profile.phone SessionStateStore.setActiveUserKey( SessionStateStore.makeUserKey(profileId: customer.id, email: customer.email) ) addresses = customer.addressBook ?? [] } else { addresses = [] } if let selected = addresses.first(where: { $0.id == appState.address.selectedId }) ?? addresses.first { appState.address.selectedId = selected.id appState.address.display = selected.label ?? "Defina seu endereco" if let lat = selected.latLong?.first, let lng = selected.latLong?.dropFirst().first { appState.address.latitude = lat appState.address.longitude = lng } SessionStateStore.saveAddress(appState.address) } } catch { errorMessage = error.localizedDescription } isLoading = false } } struct AddAddressFormView: View { @Environment(\.dismiss) var dismiss @Environment(\.colorScheme) var colorScheme @State var label = "" @State var zipCode = "" @State var address = "" @State var number = "" @State var complement = "" @State var neighborhood = "" @State var city = "" @State var state = "" @State var isLoading = false @State var isLookingUpZipCode = false @State var zipLookupMessage: String? = nil @State var lastLookedUpZipCode = "" @State var lookedUpLatitude: Double? = nil @State var lookedUpLongitude: Double? = nil let existingAddress: CustomerAddress? let onSave: ([CustomerAddress], Bool) -> Void private var isFormValid: Bool { !label.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && normalizeZipCodeForAPI(zipCode).count == 8 && !address.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && !number.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && !neighborhood.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && !city.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && !state.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } @ViewBuilder private var logoImage: some View { #if os(Android) SwiftUI.Image("pedifoods") .resizable() #else SwiftUI.Image("pedifoods") .resizable() #endif } var body: some View { ZStack { (colorScheme == .dark ? Color.black : AppColors.backgroundLight).ignoresSafeArea() ScrollView { VStack(spacing: 0) { logoImage .scaledToFit() .frame(width: 120, height: 120) Text(existingAddress == nil ? "Novo endereço" : "Editar endereço") .font(AppTypography.heading1) .foregroundStyle(colorScheme == .dark ? Color.white : AppColors.textPrimary) 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) .padding(.top, 8) .padding(.bottom, 20) VStack(alignment: .leading, spacing: 16) { LoginField(icon: "tag", placeholder: "Ex: Casa, Trabalho", text: $label) LoginField(icon: "mail", placeholder: "CEP", text: $zipCode) .onChange(of: zipCode) { _, newValue in let masked = formatZipCodeBR(newValue) if masked != newValue { zipCode = masked } let normalized = normalizeZipCodeForAPI(masked) if normalized.count == 8, normalized != lastLookedUpZipCode, !isLookingUpZipCode { Task { await lookupAddressByZipCode(normalized) } } } if isLookingUpZipCode { HStack(spacing: 8) { ProgressView() Text("Buscando endereço pelo CEP...") .font(.caption) .foregroundStyle(AppColors.textMuted) } .padding(.horizontal, 6) } else if let zipLookupMessage { Text(zipLookupMessage) .font(.caption) .foregroundStyle(AppColors.textMuted) .padding(.horizontal, 6) } LoginField(icon: "mappin.and.ellipse", placeholder: "Rua / Avenida", text: $address) LoginField(icon: "number", placeholder: "Número", text: $number) LoginField(icon: "plus.app", placeholder: "Complemento (opcional)", text: $complement) LoginField(icon: "square.grid.2x2", placeholder: "Bairro", text: $neighborhood) LoginField(icon: "building.2", placeholder: "Cidade", text: $city) LoginField(icon: "map", placeholder: "Estado (UF)", text: $state) .onChange(of: state) { _, newValue in let normalized = String(newValue.uppercased().prefix(2)) if normalized != newValue { state = normalized } } } .padding(.horizontal, 24) .padding(.bottom, 20) PrimaryButton(title: existingAddress == nil ? "Salvar endereço" : "Atualizar endereço", image: Image(systemName: "checkmark")) { saveAddress() } .padding(.horizontal, 24) .disabled(!isFormValid || isLoading) .opacity((!isFormValid || isLoading) ? 0.5 : 1.0) .tint(AppColors.tertiary) SecondaryButton(title: "Cancelar") { dismiss() } .padding(.horizontal, 24) .padding(.top, 12) Spacer().frame(height: 120) } } .padding(.top, 0) } .navigationBarBackButtonHidden(true) .toolbar(.hidden, for: .navigationBar) .onAppear { populateFromExistingAddressIfNeeded() } } private func saveAddress() { guard !isLoading else { return } let latLong: [Double]? = { if let lat = lookedUpLatitude, let lng = lookedUpLongitude { return [lat, lng] } return nil }() let newAddress = CustomerAddress( id: existingAddress?.id ?? UUID().uuidString, label: clean(label), address: clean(address), number: clean(number), complement: optional(clean(complement)), neighborhood: clean(neighborhood), city: clean(city), state: clean(state), zipCode: optional(normalizeZipCodeForAPI(zipCode)), latLong: latLong ) isLoading = true zipLookupMessage = nil Task { do { let response = try await ApiService().saveCustomerAddress(newAddress, replacingAddressId: existingAddress?.id) await MainActor.run { isLoading = false if response.error { zipLookupMessage = response.message ?? "Não foi possível salvar o endereço." SnackbarCenter.shared.show(title: zipLookupMessage ?? "Não foi possível salvar o endereço.", style: .error, icon: "xmark.octagon.fill", duration: 4.0) return } let updatedAddresses = response.result?.addressBook ?? [newAddress] onSave(updatedAddresses, existingAddress != nil) dismiss() } } catch { await MainActor.run { isLoading = false let message = error.localizedDescription zipLookupMessage = message SnackbarCenter.shared.show(title: message, style: .error, icon: "xmark.octagon.fill", duration: 4.0) } } } } 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) } private func optional(_ value: String) -> String? { value.isEmpty ? nil : value } @MainActor private func lookupAddressByZipCode(_ zip: String) async { isLookingUpZipCode = true zipLookupMessage = nil defer { isLookingUpZipCode = false } do { let response = try await ApiService().lookupZipCode(zip) lastLookedUpZipCode = zip guard response.error == false, let result = response.result else { zipLookupMessage = response.message ?? "Não foi possível consultar este CEP." return } fillAddressFields(with: result) zipLookupMessage = "Endereço preenchido automaticamente." } catch { zipLookupMessage = "Não foi possível consultar o CEP agora." } } private func fillAddressFields(with result: CepLookupResult) { if address.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { address = result.street?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" } if neighborhood.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { neighborhood = result.neighborhood?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" } if city.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { city = result.city?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" } if state.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { state = String((result.state ?? "").uppercased().prefix(2)) } if complement.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { complement = result.complement?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" } lookedUpLatitude = result.latitude lookedUpLongitude = result.longitude } } func formatZipCodeBR(_ input: String) -> String { let digits = input.filter(\.isNumber) let limited = String(digits.prefix(8)) if limited.count <= 5 { return limited } let prefix = String(limited.prefix(5)) let suffix = String(limited.dropFirst(5)) return "\(prefix)-\(suffix)" } 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) { icon VStack(alignment: .leading, spacing: 8) { HStack(spacing: 10) { 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) .lineLimit(1) .fixedSize(horizontal: true, vertical: false) .padding(.horizontal, 9) .padding(.vertical, 5) .background(AppColors.tertiary) .clipShape(Capsule()) } } Text(item.detail) .font(AppTypography.body) .foregroundStyle(AppColors.textMuted) .lineLimit(2) } Spacer(minLength: 8) if onEdit != nil || onDelete != nil { Rectangle() .fill(AppColors.backgroundLight) .frame(width: 1, height: 96) } VStack(spacing: 24) { if let onEdit { Button(action: onEdit) { Image(systemName: "pencil") .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: onEdit != nil || onDelete != nil ? 40 : 0) } .padding(.horizontal, 16) .padding(.vertical, 20) .background(AppColors.surface) .clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusXL, style: .continuous)) } var icon: some View { Image(systemName: item.icon) .font(.system(size: 28)) .foregroundStyle(item.isPrimary ? AppColors.primary : AppColors.textPrimary) .frame(width: 84, height: 84) .background(item.isPrimary ? AppColors.brandSoft : AppColors.backgroundLight) .clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous)) } } struct AddressListItem: Identifiable { let id = UUID() let title: String let detail: String let icon: String let isPrimary: Bool } struct OrdersView: View { var body: some View { VStack(spacing: 16) { Text("Pedidos") .font(AppTypography.heading1) Text("Historico vazio") .foregroundStyle(AppColors.secondary) } .frame(maxWidth: .infinity, maxHeight: .infinity) .background(AppColors.backgroundLight) } }