import SwiftUI 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 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 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 { AddressCard(item: addressToListItem(address, isPrimary: isSelected)) } } } } } .padding(.horizontal, 20) .padding(.top, 18) } VStack { Spacer() bottomOverlay .padding(.bottom, tabBarClearance) } } .navigationBarBackButtonHidden(true) .toolbar(.hidden, for: .navigationBar) .sheet(isPresented: $openAddAddressForm) { NavigationStack { AddAddressFormView { updatedAddresses 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) } } } .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: { 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() } } 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 onSave: ([CustomerAddress]) -> 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("Novo endereço") .font(AppTypography.heading1) .foregroundStyle(colorScheme == .dark ? Color.white : AppColors.textPrimary) Text("Preencha os dados abaixo para adicionar um endereço.") .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: "Salvar 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) } 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: 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().addCustomerAddress(newAddress) 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) 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 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)) } struct AddressCard: View { let item: AddressListItem 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) if item.isPrimary { Text("PRINCIPAL") .font(AppTypography.overline) .foregroundStyle(AppColors.textPrimary) .padding(.horizontal, 10) .padding(.vertical, 6) .background(AppColors.tertiary) .clipShape(Capsule()) .lineLimit(1) } } Text(item.detail) .font(AppTypography.body) .foregroundStyle(AppColors.textMuted) .lineLimit(2) } Spacer(minLength: 8) Rectangle() .fill(AppColors.backgroundLight) .frame(width: 1, height: 96) VStack(spacing: 24) { Button(action: {}) { Image(systemName: "pencil") .font(.system(size: 22)) .foregroundStyle(AppColors.textMuted) } Button(action: {}) { Image(systemName: "trash") .font(.system(size: 22)) .foregroundStyle(AppColors.textMuted) } } .frame(width: 40) } .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) } }