migration

This commit is contained in:
Daniel Arantes Loverde
2026-08-11 13:22:02 -03:00
parent c4d6998595
commit 7702836fe7
231 changed files with 4329 additions and 1958 deletions

View 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
}