[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

@@ -15,6 +15,7 @@ struct ContentView: View {
@State private var sessionExpiredObserver: NSObjectProtocol?
@State var cartResetObserver: Any?
@State var appResumeObserver: Any?
@State var pushOrderTapObserver: Any?
@StateObject var snackbarCenter = SnackbarCenter.shared
var body: some View {
@@ -90,11 +91,13 @@ struct ContentView: View {
attachCartResetObserverIfNeeded()
attachAppResumeObserverIfNeeded()
attachSessionExpiredObserverIfNeeded()
attachPushOrderTapObserverIfNeeded()
}
.onDisappear {
detachCartResetObserver()
detachAppResumeObserver()
detachSessionExpiredObserver()
detachPushOrderTapObserver()
}
}
@@ -241,6 +244,38 @@ struct ContentView: View {
self.appResumeObserver = nil
}
/// §6 of the push notifications guide an `order_status` push tap
/// (reported by `PushNotificationCoordinator`) routes here to the
/// Profile tab's Orders list, pre-targeted at that order.
private func attachPushOrderTapObserverIfNeeded() {
guard pushOrderTapObserver == nil else { return }
pushOrderTapObserver = NotificationCenter.default.addObserver(
forName: .pushTappedOrderStatus,
object: nil,
queue: nil
) { notification in
let orderId = notification.userInfo?["orderId"] as? String
let shortId = notification.userInfo?["shortId"] as? String
Task { @MainActor in
guard root == .main, let orderId else { return }
appState.pendingOrderDeepLink = OrderRouteContext(
orderId: orderId,
shortId: shortId,
paymentMethod: nil,
total: nil,
intent: .auto
)
selectedTab = .profile
}
}
}
private func detachPushOrderTapObserver() {
guard let pushOrderTapObserver else { return }
NotificationCenter.default.removeObserver(pushOrderTapObserver)
self.pushOrderTapObserver = nil
}
@MainActor
private func hydrateAppState(with customer: CustomerProfile) {
appState.profile.id = customer.id

View File

@@ -18,7 +18,15 @@ public final class PediFoodsAppDelegate: Sendable {
private init() {}
public func onInit() { logger.debug("onInit") }
public func onLaunch() { logger.debug("onLaunch") }
public func onLaunch() {
logger.debug("onLaunch")
Task { @MainActor in
PushNotificationCoordinator.shared.startObservingDeviceToken()
PushNotificationCoordinator.shared.becomeNotificationCenterDelegate()
await PushNotificationCoordinator.shared.refreshRegistrationIfAuthorized()
await PushNotificationCoordinator.shared.syncCustomerAttributes()
}
}
public func onResume() {
logger.debug("onResume")
NotificationCenter.default.post(name: .appDidResume, object: nil)

View File

@@ -89,6 +89,9 @@
"10km" : {
"comment" : "A label displayed next to the far end of the distance slider in the filters modal.",
"isCommentAutoGenerated" : true
},
"Abrir Ajustes" : {
},
"Acompanhamento em tempo real" : {
@@ -148,6 +151,9 @@
"Adicione produtos para continuar." : {
"comment" : "A message displayed when the cart is empty, encouraging the user to add products.",
"isCommentAutoGenerated" : true
},
"Agora não" : {
},
"AGUARDANDO PAGAMENTO" : {
"comment" : "A status text indicating that the payment is pending.",
@@ -212,6 +218,15 @@
"Até %lldkm" : {
"comment" : "A label displaying the maximum distance filter value in kilometers. The argument is the current maximum distance filter value in kilometers.",
"isCommentAutoGenerated" : true
},
"Ativar" : {
},
"Ative as notificações" : {
},
"Ative as notificações para acompanhar em tempo real as atualizações do seu pedido." : {
},
"Atualizando status do pedido..." : {
@@ -818,6 +833,12 @@
}
}
}
},
"Notificações" : {
},
"Notificações desativadas" : {
},
"Novo cartão" : {
@@ -879,6 +900,9 @@
},
"Pague mais rápido nas próximas compras" : {
},
"Para receber notificações sobre seus pedidos, ative a permissão nos Ajustes do iPhone." : {
},
"Pedido #%@" : {

View File

@@ -38,6 +38,53 @@ struct CustomerIdentityUpdatePayload: Encodable {
}
}
/// `POST /api/customer/:id` partial update, sibling of `CustomerProfileUpdatePayload`.
/// See docs/api/push-notifications-integration-guide.md §2b.
struct CustomerNotificationsUpdatePayload: Encodable {
let notificationsEnabled: Bool
}
/// `POST /api/customer/:id` biometric-login preference. Persistence only for
/// now; the actual Face ID/Touch ID unlock flow is a separate, later plan.
struct CustomerFaceIdUpdatePayload: Encodable {
let faceIdEnabled: Bool
}
/// `PUT /api/customer/:id/push-token` see docs/api/push-notifications-integration-guide.md §2.
struct CustomerPushTokenPayload: Encodable {
let pushToken: String
let deviceId: String
let deviceOS: String
}
/// `PUT /api/customer/:id/attributes` wholesale replace, not a merge.
/// See docs/api/push-notifications-integration-guide.md §2a.
struct CustomerAttributesUpdatePayload: Encodable {
let appVersion: String?
let attributes: [String: String]?
func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encodeIfPresent(appVersion, forKey: .appVersion)
try container.encodeIfPresent(attributes, forKey: .attributes)
}
enum CodingKeys: String, CodingKey {
case appVersion
case attributes
}
}
/// `POST /api/customer/:id/push-campaigns/opened` see
/// docs/api/push-notifications-integration-guide.md §6a.
struct PushCampaignOpenedPayload: Encodable {
let campaignId: String
}
struct PushCampaignOpenedResult: Decodable {
let recorded: Bool
}
struct CustomerAddressPayload: Encodable {
let label: String?
let address: String?

View File

@@ -30,6 +30,8 @@ struct CustomerProfile: Decodable {
let profilePicture: String?
let favorites: [String]?
let addressBook: [CustomerAddress]?
let notificationsEnabled: Bool?
let faceIdEnabled: Bool?
enum CodingKeys: String, CodingKey {
case id
@@ -39,6 +41,8 @@ struct CustomerProfile: Decodable {
case profilePicture
case favorites
case addressBook = "address_book"
case notificationsEnabled
case faceIdEnabled
}
}

View File

@@ -88,6 +88,17 @@ final class ApiService {
AppContentCache.shared.invalidate(prefix: favoritesCachePrefix)
}
/// Every successful profile mutation must leave `profile()`'s cache
/// holding the server's authoritative post-mutation state never
/// patched locally from a write-response of possibly different shape,
/// and never left merely invalidated for some future caller to lazily
/// refetch (which may never happen, leaving stale data visible
/// indefinitely within the TTL). Always does a real GET.
@discardableResult
private func refreshProfileCache() async -> ApiEnvelope<CustomerProfile>? {
try? await profile(forceRefresh: true)
}
private func scopedCacheSuffix() -> String {
let jwt = tokenStore.jwt ?? "anonymous"
if jwt.count <= 16 { return jwt }
@@ -184,10 +195,90 @@ final class ApiService {
let body = try JSONEncoder().encode(payload)
let req = ApiRequest(path: "/api/customer/profile", method: "PATCH", module: .customer, requiresAuth: true, body: body)
let envelope: ProfilePatchEnvelope = try await send(req)
AppContentCache.shared.invalidate(prefix: profileCachePrefix)
if envelope.error == false {
await refreshProfileCache()
}
return envelope
}
/// See docs/api/push-notifications-integration-guide.md §2b only reachable
/// via `POST /api/customer/:id` today, not `PATCH /profile`.
func updateNotificationsEnabled(_ enabled: Bool) async throws -> ApiEnvelope<CustomerProfile> {
let currentProfile = try await profile(forceRefresh: true)
guard currentProfile.error == false, let customer = currentProfile.result else {
throw NetworkError.invalidResponse
}
let payload = CustomerNotificationsUpdatePayload(notificationsEnabled: enabled)
let body = try JSONEncoder().encode(payload)
let req = ApiRequest(path: "/api/customer/\(customer.id)", method: "POST", module: .customer, requiresAuth: true, body: body)
let envelope: ApiEnvelope<CustomerProfile> = try await sendEnvelope(req)
if envelope.error == false {
await refreshProfileCache()
}
return envelope
}
/// Persists the biometric-login preference only no LocalAuthentication
/// wiring yet, that's a separate later plan.
func updateFaceIdEnabled(_ enabled: Bool) async throws -> ApiEnvelope<CustomerProfile> {
let currentProfile = try await profile(forceRefresh: true)
guard currentProfile.error == false, let customer = currentProfile.result else {
throw NetworkError.invalidResponse
}
let payload = CustomerFaceIdUpdatePayload(faceIdEnabled: enabled)
let body = try JSONEncoder().encode(payload)
let req = ApiRequest(path: "/api/customer/\(customer.id)", method: "POST", module: .customer, requiresAuth: true, body: body)
let envelope: ApiEnvelope<CustomerProfile> = try await sendEnvelope(req)
if envelope.error == false {
await refreshProfileCache()
}
return envelope
}
/// See docs/api/push-notifications-integration-guide.md §2.
func registerPushToken(_ token: String, deviceId: String, deviceOS: String) async throws -> ApiEnvelope<EmptyResult> {
let currentProfile = try await profile(forceRefresh: true)
guard currentProfile.error == false, let customer = currentProfile.result else {
throw NetworkError.invalidResponse
}
let payload = CustomerPushTokenPayload(pushToken: token, deviceId: deviceId, deviceOS: deviceOS)
let body = try JSONEncoder().encode(payload)
let req = ApiRequest(path: "/api/customer/\(customer.id)/push-token", method: "PUT", module: .customer, requiresAuth: true, body: body)
return try await sendEnvelope(req)
}
/// See docs/api/push-notifications-integration-guide.md §2a. Wholesale
/// replace, not a merge callers must pass every `attributes` key they
/// still want kept, not just the changed ones.
func updateCustomerAttributes(appVersion: String?, attributes: [String: String]?) async throws -> ApiEnvelope<EmptyResult> {
let currentProfile = try await profile(forceRefresh: true)
guard currentProfile.error == false, let customer = currentProfile.result else {
throw NetworkError.invalidResponse
}
let payload = CustomerAttributesUpdatePayload(appVersion: appVersion, attributes: attributes)
let body = try JSONEncoder().encode(payload)
let req = ApiRequest(path: "/api/customer/\(customer.id)/attributes", method: "PUT", module: .customer, requiresAuth: true, body: body)
return try await sendEnvelope(req)
}
/// See docs/api/push-notifications-integration-guide.md §6a. Fire on tap
/// only, for `type: "campaign"` pushes idempotent server-side.
func reportPushCampaignOpened(campaignId: String) async throws -> ApiEnvelope<PushCampaignOpenedResult> {
let currentProfile = try await profile(forceRefresh: true)
guard currentProfile.error == false, let customer = currentProfile.result else {
throw NetworkError.invalidResponse
}
let payload = PushCampaignOpenedPayload(campaignId: campaignId)
let body = try JSONEncoder().encode(payload)
let req = ApiRequest(path: "/api/customer/\(customer.id)/push-campaigns/opened", method: "POST", module: .customer, requiresAuth: true, body: body)
return try await sendEnvelope(req)
}
func addCustomerAddress(_ address: CustomerAddress) async throws -> ApiEnvelope<CustomerProfile> {
return try await saveCustomerAddress(address, replacingAddressId: nil)
}
@@ -234,7 +325,9 @@ final class ApiService {
func setDefaultAddress(addressId: String) async throws -> ApiEnvelope<EmptyResult> {
let req = ApiRequest(path: "/api/customer/addresses/\(addressId)/default", method: "PATCH", module: .customer, requiresAuth: true)
let envelope: ApiEnvelope<EmptyResult> = try await sendEnvelope(req)
AppContentCache.shared.invalidate(prefix: profileCachePrefix)
if envelope.error == false {
await refreshProfileCache()
}
return envelope
}
@@ -259,9 +352,8 @@ final class ApiService {
let body = try JSONEncoder().encode(payload)
let req = ApiRequest(path: "/api/customer/\(customerId)", method: "POST", module: .customer, requiresAuth: true, body: body)
let envelope: ApiEnvelope<CustomerProfile> = try await sendEnvelope(req)
let scopedProfileKey = "\(profileCachePrefix)\(scopedCacheSuffix())"
if envelope.error == false, envelope.result != nil {
AppContentCache.shared.set(envelope, for: scopedProfileKey, ttl: AppCacheTTL.twoHours)
if envelope.error == false {
await refreshProfileCache()
} else {
invalidateFavoritesCache()
}
@@ -488,7 +580,7 @@ final class ApiService {
let req = ApiRequest(path: "/api/customer/profile", method: "PATCH", module: .customer, requiresAuth: true, body: body)
let result: ApiEnvelope<EmptyResult> = try await sendEnvelope(req)
if result.error == false {
AppContentCache.shared.invalidate(prefix: profileCachePrefix)
await refreshProfileCache()
}
return result
}

View File

@@ -0,0 +1,215 @@
import Foundation
/// Client-side half of docs/api/push-notifications-integration-guide.md.
/// Owns OS permission state, APNs device-token registration, the
/// "enable notifications" action shared by the profile toggle and the
/// order-tracking fallback prompt (§2b), foreground/tap notification
/// handling (§6), and campaign open tracking (§6a).
enum PushAuthorizationState {
case authorized
case denied
case notDetermined
}
/// Posted when the user taps an `order_status` push (§6) so `ContentView`
/// can route to that order without this service depending on `AppState`.
extension Notification.Name {
static let pushTappedOrderStatus = Notification.Name("pushTappedOrderStatus")
}
#if os(iOS)
import UIKit
import UserNotifications
@MainActor
final class PushNotificationCoordinator: NSObject {
static let shared = PushNotificationCoordinator()
private var deviceTokenObserver: NSObjectProtocol?
private var didBecomeDelegate = false
private override init() {}
/// Call once at app launch. Listens for the device token `PediFoodsAppDelegate`
/// posts after `registerForRemoteNotifications()` resolves, and forwards it
/// to Atomenta (§2).
func startObservingDeviceToken() {
guard deviceTokenObserver == nil else { return }
deviceTokenObserver = NotificationCenter.default.addObserver(
forName: NSNotification.Name("didRegisterForRemoteNotificationsWithDeviceToken"),
object: nil,
queue: .main
) { notification in
guard let data = notification.userInfo?["deviceToken"] as? Data else { return }
let hexToken = data.map { String(format: "%02x", $0) }.joined()
Task { await PushNotificationCoordinator.shared.sendTokenToBackend(hexToken) }
}
}
/// Call once at app launch, before the first notification could possibly
/// arrive makes this the `UNUserNotificationCenterDelegate` so foreground
/// pushes actually display (§6) and taps get routed/tracked (§6, §6a).
func becomeNotificationCenterDelegate() {
guard didBecomeDelegate == false else { return }
didBecomeDelegate = true
UNUserNotificationCenter.current().delegate = self
}
/// Re-registers silently (no OS prompt) if the user already granted
/// authorization in a previous session tokens aren't guaranteed stable
/// across launches (§2, §3.3/§4.2 of the guide). Safe to call before login.
func refreshRegistrationIfAuthorized() async {
guard await currentAuthorizationState() == .authorized else { return }
UIApplication.shared.registerForRemoteNotifications()
}
func currentAuthorizationState() async -> PushAuthorizationState {
let settings = await UNUserNotificationCenter.current().notificationSettings()
switch settings.authorizationStatus {
case .authorized, .provisional, .ephemeral:
return .authorized
case .denied:
return .denied
case .notDetermined:
return .notDetermined
@unknown default:
return .notDetermined
}
}
/// Shows the OS permission dialog only if it hasn't been answered yet.
/// Always calls `registerForRemoteNotifications()` when authorized
/// including when authorization was already granted in a past session
/// so "enable" reliably produces a fresh device token this run instead of
/// relying solely on the once-per-launch refresh.
@discardableResult
private func requestAuthorizationIfNeeded() async -> Bool {
switch await currentAuthorizationState() {
case .authorized:
UIApplication.shared.registerForRemoteNotifications()
return true
case .denied:
return false
case .notDetermined:
let granted = (try? await UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .badge, .sound])) ?? false
if granted {
UIApplication.shared.registerForRemoteNotifications()
}
return granted
}
}
/// Shared body of §2b's two touchpoints: request OS permission (if
/// undetermined), then flip Atomenta's `notificationsEnabled` flag.
/// Returns the server's authoritative post-update profile callers must
/// reflect `result.notificationsEnabled` from this, not assume `true`
/// just because the request succeeded.
func enableNotifications() async -> CustomerProfile? {
guard await requestAuthorizationIfNeeded() else { return nil }
do {
let response = try await ApiService().updateNotificationsEnabled(true)
return response.error == false ? response.result : nil
} catch {
logger.error("Failed to enable push notifications: \(error.localizedDescription)")
return nil
}
}
/// See docs/api/push-notifications-integration-guide.md §2a. Best-effort,
/// silent on failure Campaign `appVersion`/`attributes` targeting just
/// won't match this user until the next successful call. Call right after
/// login and once per app launch (covers an app update since last launch).
func syncCustomerAttributes() async {
guard DefaultTokenStore().jwt != nil else { return }
do {
_ = try await ApiService().updateCustomerAttributes(appVersion: currentAppVersion(), attributes: nil)
} catch {
logger.error("Failed to sync customer attributes: \(error.localizedDescription)")
}
}
private func currentAppVersion() -> String {
(Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String)?
.trimmingCharacters(in: .whitespacesAndNewlines) ?? "0.0.0"
}
private func sendTokenToBackend(_ hexToken: String) async {
guard DefaultTokenStore().jwt != nil else { return }
do {
_ = try await ApiService().registerPushToken(hexToken, deviceId: GuestLocationStore.shared.deviceId, deviceOS: "ios")
} catch {
logger.error("Push token registration failed: \(error.localizedDescription)")
}
}
/// §6a fire on tap only, for `type: "campaign"` pushes. Idempotent
/// server-side, so no client-side "already reported" guard needed.
private func reportCampaignOpened(campaignId: String) async {
guard DefaultTokenStore().jwt != nil else { return }
do {
_ = try await ApiService().reportPushCampaignOpened(campaignId: campaignId)
} catch {
logger.error("Failed to report campaign open: \(error.localizedDescription)")
}
}
/// §6 routes on the tapped push's `data` payload. `order_status` gets
/// forwarded to `ContentView` via `NotificationCenter` (this service has
/// no `AppState` binding of its own); `campaign` self-reports its open.
fileprivate func handleTap(userInfo: [AnyHashable: Any]) {
guard let type = userInfo["type"] as? String else { return }
switch type {
case "campaign":
guard let campaignId = userInfo["campaignId"] as? String else { return }
Task { await reportCampaignOpened(campaignId: campaignId) }
case "order_status":
guard let orderId = userInfo["orderId"] as? String else { return }
let shortId = userInfo["shortId"] as? String
NotificationCenter.default.post(
name: .pushTappedOrderStatus,
object: nil,
userInfo: ["orderId": orderId, "shortId": shortId as Any]
)
default:
break
}
}
}
extension PushNotificationCoordinator: UNUserNotificationCenterDelegate {
/// Without a delegate, iOS silently drops push notifications while the
/// app is foregrounded this is what makes them display as a banner too.
nonisolated func userNotificationCenter(
_ center: UNUserNotificationCenter,
willPresent notification: UNNotification,
withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void
) {
completionHandler([.banner, .list, .sound, .badge])
}
nonisolated func userNotificationCenter(
_ center: UNUserNotificationCenter,
didReceive response: UNNotificationResponse,
withCompletionHandler completionHandler: @escaping () -> Void
) {
let userInfo = response.notification.request.content.userInfo
Task { @MainActor in
PushNotificationCoordinator.shared.handleTap(userInfo: userInfo)
}
completionHandler()
}
}
#else
@MainActor
final class PushNotificationCoordinator {
static let shared = PushNotificationCoordinator()
private init() {}
func startObservingDeviceToken() {}
func becomeNotificationCenterDelegate() {}
func refreshRegistrationIfAuthorized() async {}
func currentAuthorizationState() async -> PushAuthorizationState { .denied }
func enableNotifications() async -> CustomerProfile? { nil }
func syncCustomerAttributes() async {}
}
#endif

View File

@@ -88,6 +88,19 @@ enum SessionStateStore {
private static let pendingCartOrderKeyPrefix = "session.cart.pending-order.v1."
private static let orderReviewsKeyPrefix = "session.orders.reviews.v1."
private static let orderReviewDraftKeyPrefix = "session.orders.review-draft.v1."
private static let pushOptInPromptKey = "session.push.opt-in.last-prompted.v1"
private static let pushOptInCooldown: TimeInterval = 60 * 60 * 24
/// See docs/api/push-notifications-integration-guide.md §2b avoid
/// re-prompting the order-tracking fallback alert on every screen visit.
static func shouldPromptPushOptIn() -> Bool {
guard let last = UserDefaults.standard.object(forKey: pushOptInPromptKey) as? Date else { return true }
return Date().timeIntervalSince(last) > pushOptInCooldown
}
static func recordPushOptInPrompted() {
UserDefaults.standard.set(Date(), forKey: pushOptInPromptKey)
}
static func makeUserKey(profileId: String?, email: String?) -> String? {
let id = (profileId ?? "")

View File

@@ -10,6 +10,10 @@ struct AppState {
var homeFilters = HomeFiltersState()
var activeModal: AppModal? = nil
var shouldNavigateToOrders: Bool = false
/// Set when a push tap (§6 of the push notifications guide) targets a
/// specific order consumed once by `OrdersView`, which routes to it via
/// `OrderEntryDestinationView` and clears it.
var pendingOrderDeepLink: OrderRouteContext? = nil
}
enum FeatureFlagValue: Codable, Equatable {
@@ -94,6 +98,8 @@ struct ProfileState {
var phone: String = ""
var profilePicture: String = ""
var cpf: String = ""
var notificationsEnabled: Bool = false
var faceIdEnabled: Bool = false
}
struct AddressState {

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 {