[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:
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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