[push-deeplink-routing-contract] Generalize push tap routing to DeepLinkDestination
Replace order_status-only NotificationCenter path with a single DeepLinkDestination enum + PushDeepLinkParser, decoded once in PushNotificationCoordinator and dispatched via ContentView.route(to:). Also fixes NotificationService reading userInfo["image"] instead of the guide's stale "imageUrl" key.
This commit is contained in:
@@ -2,7 +2,9 @@ import UserNotifications
|
||||
|
||||
/// Rich (image) push — see docs/api/push-notifications-integration-guide.md §4.3.
|
||||
/// Only fires when Atomenta's push payload sets `"mutable-content": 1`, which
|
||||
/// it does by default on any push carrying an `imageUrl`.
|
||||
/// it does by default on any push carrying an image (real wire key is
|
||||
/// `"image"`, not the guide's `"imageUrl"` — verified against
|
||||
/// `PushNotificationService.dispatchApns`/`dispatchApnsMany` in Atomenta).
|
||||
/// `@unchecked Sendable`: the extension process only ever runs one
|
||||
/// `didReceive`/`serviceExtensionTimeWillExpire` cycle at a time — there's no
|
||||
/// real concurrent access to `contentHandler`/`bestAttemptContent` to guard
|
||||
@@ -17,7 +19,7 @@ final class NotificationService: UNNotificationServiceExtension, @unchecked Send
|
||||
let mutableContent = (request.content.mutableCopy() as? UNMutableNotificationContent) ?? UNMutableNotificationContent()
|
||||
bestAttemptContent = mutableContent
|
||||
|
||||
guard let imageURLString = request.content.userInfo["imageUrl"] as? String,
|
||||
guard let imageURLString = request.content.userInfo["image"] as? String,
|
||||
let imageURL = URL(string: imageURLString) else {
|
||||
deliver()
|
||||
return
|
||||
|
||||
@@ -15,7 +15,7 @@ struct ContentView: View {
|
||||
@State private var sessionExpiredObserver: NSObjectProtocol?
|
||||
@State var cartResetObserver: Any?
|
||||
@State var appResumeObserver: Any?
|
||||
@State var pushOrderTapObserver: Any?
|
||||
@State var pushDeepLinkObserver: Any?
|
||||
@StateObject var snackbarCenter = SnackbarCenter.shared
|
||||
|
||||
var body: some View {
|
||||
@@ -91,13 +91,13 @@ struct ContentView: View {
|
||||
attachCartResetObserverIfNeeded()
|
||||
attachAppResumeObserverIfNeeded()
|
||||
attachSessionExpiredObserverIfNeeded()
|
||||
attachPushOrderTapObserverIfNeeded()
|
||||
attachPushDeepLinkObserverIfNeeded()
|
||||
}
|
||||
.onDisappear {
|
||||
detachCartResetObserver()
|
||||
detachAppResumeObserver()
|
||||
detachSessionExpiredObserver()
|
||||
detachPushOrderTapObserver()
|
||||
detachPushDeepLinkObserver()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -244,36 +244,50 @@ 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,
|
||||
/// §6 of the push notifications guide, generalized: any tapped push that
|
||||
/// resolves to a `DeepLinkDestination` (reported by
|
||||
/// `PushNotificationCoordinator`) lands here. Adding a future promo/coupon
|
||||
/// screen means adding one case to `route(to:)` below — this observer,
|
||||
/// the notification name, and the AppState plumbing for it stay put.
|
||||
private func attachPushDeepLinkObserverIfNeeded() {
|
||||
guard pushDeepLinkObserver == nil else { return }
|
||||
pushDeepLinkObserver = NotificationCenter.default.addObserver(
|
||||
forName: .pushDeepLinkReceived,
|
||||
object: nil,
|
||||
queue: nil
|
||||
) { notification in
|
||||
let orderId = notification.userInfo?["orderId"] as? String
|
||||
let shortId = notification.userInfo?["shortId"] as? String
|
||||
guard let destination = notification.userInfo?["destination"] as? DeepLinkDestination else { return }
|
||||
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
|
||||
route(to: destination)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func detachPushOrderTapObserver() {
|
||||
guard let pushOrderTapObserver else { return }
|
||||
NotificationCenter.default.removeObserver(pushOrderTapObserver)
|
||||
self.pushOrderTapObserver = nil
|
||||
private func detachPushDeepLinkObserver() {
|
||||
guard let pushDeepLinkObserver else { return }
|
||||
NotificationCenter.default.removeObserver(pushDeepLinkObserver)
|
||||
self.pushDeepLinkObserver = nil
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func route(to destination: DeepLinkDestination) {
|
||||
guard root == .main else { return }
|
||||
switch destination {
|
||||
case .orderTracking(let orderId, let shortId):
|
||||
appState.pendingOrderDeepLink = OrderRouteContext(
|
||||
orderId: orderId,
|
||||
shortId: shortId,
|
||||
paymentMethod: nil,
|
||||
total: nil,
|
||||
intent: .auto
|
||||
)
|
||||
selectedTab = .profile
|
||||
case .screen(let name, let params):
|
||||
// Promo/coupon screens (participating stores/products, §6's
|
||||
// `targetScreen` convention) land here once they exist — see
|
||||
// decisions/2026-08-04-push-deeplink-routing-contract.md.
|
||||
logger.debug("Unhandled deep-link screen: \(name, privacy: .public) params=\(params, privacy: .public)")
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
|
||||
49
Sources/PediFoods/Coordinators/DeepLinkDestination.swift
Normal file
49
Sources/PediFoods/Coordinators/DeepLinkDestination.swift
Normal file
@@ -0,0 +1,49 @@
|
||||
import Foundation
|
||||
|
||||
/// Single decode target for anything that can deep-link into the app — push
|
||||
/// notification taps today (docs/api/push-notifications-integration-guide.md
|
||||
/// §6), in-app promo/discount banner taps tomorrow (same shape: a string-keyed
|
||||
/// dictionary naming a screen + its params). Adding a new destination means
|
||||
/// adding one case here + one branch in `ContentView.route(to:)` — never a new
|
||||
/// `NotificationCenter` name, `AppState` field, or observer pair.
|
||||
enum DeepLinkDestination: Equatable {
|
||||
/// `type: "order_status"` — the only push type Atomenta fixes today
|
||||
/// (§6), so it gets its own case instead of routing through `targetScreen`.
|
||||
case orderTracking(orderId: String, shortId: String?)
|
||||
|
||||
/// Everything else, keyed by `targetScreen` — our own convention (§6
|
||||
/// leaves this to us). Once a real screen exists for a given name (e.g.
|
||||
/// future promo/coupon "list of participating stores/products"), give it
|
||||
/// its own case; until then this carries the raw params so nothing is
|
||||
/// silently dropped.
|
||||
case screen(name: String, params: [String: String])
|
||||
}
|
||||
|
||||
/// Resolves the *navigation* half of a tapped push's payload. Pure and
|
||||
/// side-effect-free on purpose: easy to unit test, and reusable as-is for
|
||||
/// in-app banner taps once those exist. Campaign-open reporting (§6a) is a
|
||||
/// separate side effect, not a navigation target, and is not decided here —
|
||||
/// see `PushNotificationCoordinator.handleTap`.
|
||||
struct PushDeepLinkParser {
|
||||
static func parse(_ userInfo: [AnyHashable: Any]) -> DeepLinkDestination? {
|
||||
if let type = userInfo["type"] as? String, type == "order_status" {
|
||||
// Atomenta's real order-status push (PushNotificationService.
|
||||
// notifyOrderStatusUpdate) sends only `shortId`, never `orderId` —
|
||||
// `GET /api/public/orders/:orderId` accepts either as the lookup
|
||||
// key server-side, so `shortId` doubles as the id here too.
|
||||
let shortId = userInfo["shortId"] as? String
|
||||
guard let orderId = (userInfo["orderId"] as? String) ?? shortId else { return nil }
|
||||
return .orderTracking(orderId: orderId, shortId: shortId)
|
||||
}
|
||||
|
||||
guard let targetScreen = userInfo["targetScreen"] as? String else { return nil }
|
||||
var params: [String: String] = [:]
|
||||
for (key, value) in userInfo {
|
||||
guard let key = key as? String, key != "type", key != "targetScreen" else { continue }
|
||||
if let stringValue = value as? String {
|
||||
params[key] = stringValue
|
||||
}
|
||||
}
|
||||
return .screen(name: targetScreen, params: params)
|
||||
}
|
||||
}
|
||||
@@ -11,10 +11,12 @@ enum PushAuthorizationState {
|
||||
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`.
|
||||
/// Posted when a tapped push resolves to a navigable `DeepLinkDestination`
|
||||
/// (§6) so `ContentView` can route without this service depending on
|
||||
/// `AppState`. One name for every destination, present and future — see
|
||||
/// `DeepLinkDestination`.
|
||||
extension Notification.Name {
|
||||
static let pushTappedOrderStatus = Notification.Name("pushTappedOrderStatus")
|
||||
static let pushDeepLinkReceived = Notification.Name("pushDeepLinkReceived")
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
@@ -153,26 +155,21 @@ final class PushNotificationCoordinator: NSObject {
|
||||
}
|
||||
}
|
||||
|
||||
/// §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.
|
||||
/// §6 — routes on the tapped push's `data` payload. Campaign-open
|
||||
/// reporting (§6a, a side effect, not a navigation target) is decided
|
||||
/// directly on `type` here; navigation is delegated to
|
||||
/// `PushDeepLinkParser` and forwarded to `ContentView` via
|
||||
/// `NotificationCenter` (this service has no `AppState` binding of its
|
||||
/// own). A push can do both — e.g. a future campaign that also sets
|
||||
/// `targetScreen` reports its open *and* navigates.
|
||||
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 }
|
||||
if let type = userInfo["type"] as? String, type == "campaign",
|
||||
let campaignId = userInfo["campaignId"] as? String {
|
||||
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
|
||||
}
|
||||
|
||||
guard let destination = PushDeepLinkParser.parse(userInfo) else { return }
|
||||
NotificationCenter.default.post(name: .pushDeepLinkReceived, object: nil, userInfo: ["destination": destination])
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user