import SwiftUI #if os(iOS) import PhotosUI import UIKit #endif #if canImport(LCEssentials) import LCEssentials #endif struct UserProfileView: View { @Binding var appState: AppState @Environment(\.dismiss) var dismiss @Environment(\.openURL) private var openURL @State var name: String = "" @State var email: String = "" @State var phone: String = "" @State var cpf: String = "" @State var profilePicture: String = "" @State var isSaving = false @State private var notificationsEnabled = false @State private var isTogglingNotifications = false @State private var showNotificationsDeniedAlert = false @State private var faceIdEnabled = false @State private var isTogglingFaceId = false #if os(iOS) @State private var selectedPhotoItem: PhotosPickerItem? #endif var body: some View { LCENavigationView { content } .setLeftButton(image: AnyView(AppBackButtonIcon())) { dismiss() } .setTitle(text: Text("Meu Perfil").font(AppTypography.heading2).foregroundStyle(AppColors.textPrimary)) .setNavigationBarBackgroundColor(AppColors.backgroundLight) .buttonStyle(.plain) } private var content: some View { ScrollView(showsIndicators: false) { VStack(spacing: 22) { avatarSection formSection preferencesSection saveButton } .padding(.horizontal, 20) .padding(.top, 18) .padding(.bottom, UIDevice.bottomNotch + 24) } .background(AppColors.backgroundLight) .appHiddenNavigationBar() .navigationBarBackButtonHidden(true) .alert("Notificações desativadas", isPresented: $showNotificationsDeniedAlert) { Button("Agora não", role: .cancel) {} Button("Abrir Ajustes") { openSystemSettings() } } message: { Text("Para receber notificações sobre seus pedidos, ative a permissão nos Ajustes do iPhone.") } .onAppear { hydrateFromAppState() } #if os(iOS) .onChange(of: selectedPhotoItem) { _, newItem in Task { await applySelectedPhoto(newItem) } } #endif } private var avatarSection: some View { VStack(spacing: 12) { Circle() .fill(AppColors.brandSoft) .frame(width: 110, height: 110) .overlay { if let imageSource = resolvedProfilePicture { AsyncStoreImage(imageURL: imageSource) .frame(width: 104, height: 104) .clipShape(Circle()) } else { Text(initials) .font(.system(size: 32, weight: .bold)) .foregroundStyle(AppColors.primary) } } HStack(spacing: 10) { #if os(iOS) PhotosPicker(selection: $selectedPhotoItem, matching: .images, photoLibrary: .shared()) { Text("Trocar Foto") .font(AppTypography.caption) .foregroundStyle(AppColors.primary) .padding(.horizontal, 12) .padding(.vertical, 8) .background(AppColors.surface) .clipShape(Capsule()) } #endif Button("Remover") { profilePicture = "" } .font(AppTypography.caption) .foregroundStyle(AppColors.textMuted) .buttonStyle(.plain) .disabled(resolvedProfilePicture == nil) } } .frame(maxWidth: .infinity) } private var formSection: some View { VStack(alignment: .leading, spacing: 14) { textFieldSection(title: "Nome", placeholder: "Seu nome completo", text: $name) textFieldSection(title: "E-mail", placeholder: "seu@email.com", text: $email) .appNoAutoCap() textFieldSection(title: "Telefone", placeholder: "(00) 00000-0000", text: $phone) .onChange(of: phone) { _, newValue in let masked = formatPhoneBR(displayPhoneDigits(newValue)) if masked != newValue { phone = masked } } textFieldSection(title: "CPF", placeholder: "000.000.000-00", text: $cpf) .keyboardType(.numberPad) .onChange(of: cpf) { _, newValue in let digits = newValue.filter(\.isNumber) let masked = formatCPF(digits) if masked != newValue { cpf = masked } } .appNoAutoCap() } .padding(16) .background(AppColors.surface) .clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous)) } private var preferencesSection: some View { VStack(alignment: .leading, spacing: 14) { toggleRow( icon: "bell.fill", title: "Notificações", isOn: $notificationsEnabled, isDisabled: isTogglingNotifications ) .onChange(of: notificationsEnabled) { _, newValue in Task { await handleNotificationsToggle(newValue) } } Divider() toggleRow( icon: "faceid", title: "Login com biometria", isOn: $faceIdEnabled, isDisabled: isTogglingFaceId ) .onChange(of: faceIdEnabled) { _, newValue in Task { await handleFaceIdToggle(newValue) } } } .padding(16) .background(AppColors.surface) .clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous)) } private func toggleRow(icon: String, title: String, isOn: Binding, isDisabled: Bool) -> some View { HStack(spacing: 12) { Image(systemName: icon) .font(.system(size: 18, weight: .semibold)) .foregroundStyle(AppColors.primary) .frame(width: 28) Text(title) .font(AppTypography.body) .foregroundStyle(AppColors.textPrimary) Spacer(minLength: 10) Toggle("", isOn: isOn) .labelsHidden() .disabled(isDisabled) } } private var saveButton: some View { Button(isSaving ? "Salvando..." : "Salvar Alterações") { Task { await saveProfile() } } .font(AppTypography.button) .foregroundStyle(AppColors.textInverse) .frame(maxWidth: .infinity, minHeight: 56) .background(AppColors.primary) .clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous)) .buttonStyle(.plain) .disabled(isSaving || canSave == false) .opacity((isSaving || canSave == false) ? 0.6 : 1.0) } private var resolvedProfilePicture: String? { let trimmed = profilePicture.trimmingCharacters(in: .whitespacesAndNewlines) guard trimmed.isEmpty == false else { return nil } return ImageSourceResolver.resolve(trimmed) } private var initials: String { let parts = name .trimmingCharacters(in: .whitespacesAndNewlines) .split(separator: " ") .prefix(2) let letters = parts.compactMap { $0.first }.map(String.init).joined() return letters.isEmpty ? "PF" : letters.uppercased() } private var canSave: Bool { let cleanName = name.trimmingCharacters(in: .whitespacesAndNewlines) let cleanEmail = email.trimmingCharacters(in: .whitespacesAndNewlines) let normalizedPhone = normalizePhoneNumberForAPI(phone) return cleanName.isEmpty == false && cleanEmail.isEmpty == false && cleanEmail.contains("@") && normalizedPhone.isEmpty == false } private func hydrateFromAppState() { name = appState.profile.name email = appState.profile.email phone = formatPhoneForDisplay(appState.profile.phone) profilePicture = appState.profile.profilePicture cpf = formatCPF(appState.profile.cpf.filter(\.isNumber)) notificationsEnabled = appState.profile.notificationsEnabled faceIdEnabled = appState.profile.faceIdEnabled } /// Touchpoint 1 of docs/api/push-notifications-integration-guide.md §2b — /// request OS permission (if needed) before flipping the server-side flag; /// revert the toggle and explain why if the OS denies it. @MainActor private func handleNotificationsToggle(_ enabled: Bool) async { guard isTogglingNotifications == false, enabled != appState.profile.notificationsEnabled else { return } isTogglingNotifications = true defer { isTogglingNotifications = false } if enabled { if let profile = await PushNotificationCoordinator.shared.enableNotifications() { let serverValue = profile.notificationsEnabled ?? false appState.profile.notificationsEnabled = serverValue notificationsEnabled = serverValue } else { notificationsEnabled = false showNotificationsDeniedAlert = true } return } do { let response = try await ApiService().updateNotificationsEnabled(false) if response.error { notificationsEnabled = true SnackbarCenter.shared.show( title: response.message ?? "Não foi possível atualizar suas notificações.", style: .error, icon: "xmark.octagon.fill", duration: 3.0 ) } else { let serverValue = response.result?.notificationsEnabled ?? false appState.profile.notificationsEnabled = serverValue notificationsEnabled = serverValue } } catch { notificationsEnabled = true SnackbarCenter.shared.show( title: "Não foi possível atualizar suas notificações.", style: .error, icon: "xmark.octagon.fill", duration: 3.0 ) } } private func openSystemSettings() { if let url = URL(string: UIApplication.openSettingsURLString) { openURL(url) } } /// Preference only for now — the actual Face ID/Touch ID unlock flow /// (LocalAuthentication) is a separate, later plan. @MainActor private func handleFaceIdToggle(_ enabled: Bool) async { guard isTogglingFaceId == false, enabled != appState.profile.faceIdEnabled else { return } isTogglingFaceId = true defer { isTogglingFaceId = false } do { let response = try await ApiService().updateFaceIdEnabled(enabled) if response.error { faceIdEnabled = appState.profile.faceIdEnabled SnackbarCenter.shared.show( title: response.message ?? "Não foi possível atualizar essa preferência.", style: .error, icon: "xmark.octagon.fill", duration: 3.0 ) } else { let serverValue = response.result?.faceIdEnabled ?? enabled appState.profile.faceIdEnabled = serverValue faceIdEnabled = serverValue } } catch { faceIdEnabled = appState.profile.faceIdEnabled SnackbarCenter.shared.show( title: "Não foi possível atualizar essa preferência.", style: .error, icon: "xmark.octagon.fill", duration: 3.0 ) } } private func formatCPF(_ digits: String) -> String { let d = String(digits.prefix(11)) if d.count <= 3 { return d } if d.count <= 6 { return "\(d.prefix(3)).\(d.dropFirst(3))" } if d.count <= 9 { return "\(d.prefix(3)).\(d.dropFirst(3).prefix(3)).\(d.dropFirst(6))" } return "\(d.prefix(3)).\(d.dropFirst(3).prefix(3)).\(d.dropFirst(6).prefix(3))-\(d.dropFirst(9))" } @MainActor private func saveProfile() async { guard canSave else { return } let cleanName = name.trimmingCharacters(in: .whitespacesAndNewlines) let cleanEmail = email.trimmingCharacters(in: .whitespacesAndNewlines) let normalizedPhone = normalizePhoneNumberForAPI(phone) let cleanPhoto = profilePicture.trimmingCharacters(in: .whitespacesAndNewlines) let newPhoto = cleanPhoto.hasPrefix("data:") ? cleanPhoto : nil isSaving = true defer { isSaving = false } do { let response = try await ApiService().updateCustomerProfile( name: cleanName, email: cleanEmail, phoneNumber: normalizedPhone, profilePicture: newPhoto ) if response.error { SnackbarCenter.shared.show( title: response.message ?? "Não foi possível atualizar seu perfil.", style: .error, icon: "xmark.octagon.fill", duration: 3.0 ) return } appState.profile.name = cleanName appState.profile.email = cleanEmail appState.profile.phone = normalizedPhone if let pictureUrl = response.profilePictureUrl, pictureUrl.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false { appState.profile.profilePicture = ImageSourceResolver.resolve(pictureUrl) ?? pictureUrl } else if cleanPhoto.isEmpty == false { appState.profile.profilePicture = cleanPhoto } let cleanCpf = cpf.filter(\.isNumber) if cleanCpf.count == 11 { guard isValidCPF(cleanCpf) else { SnackbarCenter.shared.show( title: "CPF inválido. Verifique e tente novamente.", style: .error, icon: "xmark.octagon.fill", duration: 3.0 ) return } let cpfResponse = try await ApiService().updateProfileCpf(cpf: cleanCpf) if cpfResponse.error { SnackbarCenter.shared.show( title: cpfResponse.message ?? "Não foi possível atualizar o CPF.", style: .error, icon: "xmark.octagon.fill", duration: 3.0 ) return } appState.profile.cpf = cleanCpf } SessionStateStore.setActiveUserKey( SessionStateStore.makeUserKey(profileId: appState.profile.id, email: appState.profile.email) ) SnackbarCenter.shared.show( title: "Perfil atualizado com sucesso.", style: .success, icon: "checkmark.circle.fill", duration: 2.0 ) dismiss() } catch { SnackbarCenter.shared.show( title: "Não foi possível atualizar seu perfil.", style: .error, icon: "xmark.octagon.fill", duration: 3.0 ) } } private func formatPhoneForDisplay(_ raw: String) -> String { let digits = displayPhoneDigits(raw) if digits.isEmpty { return "" } return formatPhoneBR(digits) } private func displayPhoneDigits(_ raw: String) -> String { var digits = raw.filter(\.isNumber) if digits.hasPrefix("55"), digits.count > 11 { digits = String(digits.dropFirst(2)) } return String(digits.prefix(11)) } private func textFieldSection(title: String, placeholder: String, text: Binding) -> some View { VStack(alignment: .leading, spacing: 8) { Text(title) .font(AppTypography.caption) .foregroundStyle(AppColors.textMuted) TextField(placeholder, text: text) .font(AppTypography.body) .foregroundStyle(AppColors.textPrimary) .padding(.horizontal, 12) .frame(height: 50) .background(AppColors.backgroundLight) .clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous)) .overlay( RoundedRectangle(cornerRadius: 14, style: .continuous) .stroke(AppColors.secondary.opacity(0.2), lineWidth: 1) ) } } private func isValidCPF(_ digits: String) -> Bool { guard digits.count == 11, digits.unicodeScalars.allSatisfy({ CharacterSet.decimalDigits.contains($0) }) else { return false } guard Set(digits).count > 1 else { return false } func checkDigit(_ d: String, _ length: Int) -> Bool { let sum = d.prefix(length).enumerated().reduce(0) { acc, pair in acc + (Int(String(pair.element)) ?? 0) * (length + 1 - pair.offset) } let rem = (sum * 10) % 11 let expected = rem == 10 ? 0 : rem return Int(String(d[d.index(d.startIndex, offsetBy: length)])) == expected } return checkDigit(digits, 9) && checkDigit(digits, 10) } #if os(iOS) @MainActor private func applySelectedPhoto(_ item: PhotosPickerItem?) async { guard let item else { return } do { guard let data = try await item.loadTransferable(type: Data.self) else { return } guard let image = UIImage(data: data) else { return } let resized = resizedIfNeeded(image, maxSide: 600) guard let jpegData = resized.jpegData(compressionQuality: 0.8) else { return } profilePicture = "data:image/jpeg;base64,\(jpegData.base64EncodedString())" } catch { SnackbarCenter.shared.show( title: "Não foi possível carregar a foto selecionada.", style: .warning, icon: "photo", duration: 2.5 ) } } private func resizedIfNeeded(_ image: UIImage, maxSide: CGFloat) -> UIImage { let w = image.size.width let h = image.size.height guard w > maxSide || h > maxSide else { return image } let scale = maxSide / max(w, h) let newSize = CGSize(width: w * scale, height: h * scale) let renderer = UIGraphicsImageRenderer(size: newSize) return renderer.image { _ in image.draw(in: CGRect(origin: .zero, size: newSize)) } } #endif }