[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:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user