[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

@@ -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 {