migration
This commit is contained in:
507
PediFoods/Views/Main/UserProfileView.swift
Normal file
507
PediFoods/Views/Main/UserProfileView.swift
Normal file
@@ -0,0 +1,507 @@
|
||||
import SwiftUI
|
||||
#if os(iOS)
|
||||
import PhotosUI
|
||||
import UIKit
|
||||
#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 {
|
||||
ScrollView(showsIndicators: false) {
|
||||
VStack(spacing: 22) {
|
||||
screenHeader
|
||||
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 screenHeader: some View {
|
||||
ZStack {
|
||||
Text("Meu Perfil")
|
||||
.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)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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<Bool>, 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<String>) -> 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
|
||||
}
|
||||
Reference in New Issue
Block a user