[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

@@ -250,6 +250,7 @@ struct OtpView: View {
appState.session.jwt = response.result?.token
let hasServerAddress = hydrateUserState(from: response.result?.customer)
routeAfterLogin(hasServerAddress: hasServerAddress)
Task { await PushNotificationCoordinator.shared.syncCustomerAttributes() }
}
} catch {
await MainActor.run {
@@ -281,6 +282,8 @@ struct OtpView: View {
appState.profile.email = customer.email
appState.profile.phone = customer.phoneNumber ?? ""
appState.profile.profilePicture = customer.profilePicture ?? ""
appState.profile.notificationsEnabled = customer.notificationsEnabled ?? false
appState.profile.faceIdEnabled = customer.faceIdEnabled ?? false
appState.favorites.storeIds = Set(customer.favorites ?? [])
SessionStateStore.setActiveUserKey(
SessionStateStore.makeUserKey(profileId: customer.id, email: customer.email)

View File

@@ -386,6 +386,8 @@ struct AddressesView: View {
appState.profile.email = customer.email
appState.profile.phone = customer.phoneNumber ?? appState.profile.phone
appState.profile.profilePicture = customer.profilePicture ?? ""
appState.profile.notificationsEnabled = customer.notificationsEnabled ?? false
appState.profile.faceIdEnabled = customer.faceIdEnabled ?? false
SessionStateStore.setActiveUserKey(
SessionStateStore.makeUserKey(profileId: customer.id, email: customer.email)
)

View File

@@ -80,6 +80,8 @@ extension CheckoutView {
appState.profile.phone = phoneNumber
}
appState.profile.profilePicture = customer.profilePicture ?? ""
appState.profile.notificationsEnabled = customer.notificationsEnabled ?? false
appState.profile.faceIdEnabled = customer.faceIdEnabled ?? false
SessionStateStore.setActiveUserKey(
SessionStateStore.makeUserKey(profileId: customer.id, email: customer.email)
)

View File

@@ -25,6 +25,7 @@ struct OrderTrackingView: View {
@State var reviewDraft: ReviewDraft? = nil
@State var didSaveReviewForCurrentOrder = false
@State var reviewSavedObserver: Any?
@State var showPushOptInAlert = false
var body: some View {
ScrollView(showsIndicators: false) {
@@ -53,8 +54,15 @@ struct OrderTrackingView: View {
} message: {
Text(cancellationReasonText)
}
.alert("Ative as notificações", isPresented: $showPushOptInAlert) {
Button("Agora não", role: .cancel) {}
Button("Ativar") { Task { await enablePushNotifications() } }
} message: {
Text("Ative as notificações para acompanhar em tempo real as atualizações do seu pedido.")
}
.task {
await loadInitialOrder()
await maybePromptPushOptIn()
tracker.onOrderUpdated = { updated in
order = updated
if let inlinePhone = updated.storePhone, inlinePhone.isEmpty == false {
@@ -62,6 +70,7 @@ struct OrderTrackingView: View {
}
isLoading = false
errorMessage = nil
Task { await maybePromptPushOptIn() }
}
tracker.start(orderId: orderId, jwt: DefaultTokenStore().jwt)
}
@@ -827,6 +836,38 @@ struct OrderTrackingView: View {
isLoading = false
}
/// Touchpoint 2 of docs/api/push-notifications-integration-guide.md §2b
/// last practical moment to recover an opted-out user before order-status
/// push (§6, `type: "order_status"`) goes silent for them for this order.
@MainActor
private func maybePromptPushOptIn() async {
guard showPushOptInAlert == false, isWaitingPayment == false, isCanceled == false else { return }
guard SessionStateStore.shouldPromptPushOptIn() else { return }
let osAuthorized = await PushNotificationCoordinator.shared.currentAuthorizationState() == .authorized
var serverEnabled = false
if let profileResponse = try? await ApiService().profile(), profileResponse.error == false {
serverEnabled = profileResponse.result?.notificationsEnabled ?? false
}
guard osAuthorized == false || serverEnabled == false else { return }
SessionStateStore.recordPushOptInPrompted()
showPushOptInAlert = true
}
@MainActor
private func enablePushNotifications() async {
let profile = await PushNotificationCoordinator.shared.enableNotifications()
if profile?.notificationsEnabled != true {
SnackbarCenter.shared.show(
title: "Ative notificações nos Ajustes do iPhone para acompanhar seu pedido.",
style: .warning,
icon: "bell.slash.fill",
duration: 3.5
)
}
}
@MainActor
private func refreshStoreContactPhone(for order: PublicOrderResult) async {
guard let storeId = order.storeId?.trimmingCharacters(in: .whitespacesAndNewlines), storeId.isEmpty == false else {

View File

@@ -51,6 +51,12 @@ struct OrdersView: View {
await loadOrdersIfNeeded()
await refreshStoreRatings()
}
.onAppear {
if let pending = appState.pendingOrderDeepLink {
appState.pendingOrderDeepLink = nil
selectedOrderRoute = pending
}
}
.refreshable {
// Decoupled from .refreshable's own cancellable wrapping Task
// see StoreDetailView's .refreshable for why.

View File

@@ -181,6 +181,11 @@ struct ProfileView: View {
openOrders = true
}
}
.onChange(of: appState.pendingOrderDeepLink) { _, val in
if val != nil {
openOrders = true
}
}
}
private var header: some View {

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 {