[push/opt-in-prompts] Implement push notifications client integration

Build the full client half of docs/api/push-notifications-integration-guide.md:
OS permission + APNs device-token registration and pipeline wiring, profile
notifications/biometric-login toggles on the Ver Perfil screen reflecting
server truth, order-tracking opt-in fallback prompt, profile-cache refresh
on every mutation, a Notification Service Extension for rich/image push,
the Push Notifications capability, targeting-attributes sync, campaign open
tracking, and tap-to-order deep linking with foreground notification display.
This commit is contained in:
Daniel Arantes Loverde
2026-08-04 11:33:56 -03:00
parent 32f12d6c0e
commit 7dd4cda1a4
20 changed files with 943 additions and 12 deletions

View File

@@ -7,6 +7,7 @@ import UIKit
struct UserProfileView: View {
@Binding var appState: AppState
@Environment(\.dismiss) var dismiss
@Environment(\.openURL) private var openURL
@State var name: String = ""
@State var email: String = ""
@@ -14,6 +15,11 @@ struct UserProfileView: View {
@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?
@@ -25,6 +31,7 @@ struct UserProfileView: View {
screenHeader
avatarSection
formSection
preferencesSection
saveButton
}
.padding(.horizontal, 20)
@@ -34,6 +41,12 @@ struct UserProfileView: View {
.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()
}
@@ -135,6 +148,54 @@ struct UserProfileView: View {
.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() }
@@ -180,6 +241,97 @@ struct UserProfileView: View {
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 canImport(UIKit)
if let url = URL(string: UIApplication.openSettingsURLString) {
openURL(url)
}
#endif
}
/// 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 {