[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.
|
/// 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
|
/// 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
|
/// `@unchecked Sendable`: the extension process only ever runs one
|
||||||
/// `didReceive`/`serviceExtensionTimeWillExpire` cycle at a time — there's no
|
/// `didReceive`/`serviceExtensionTimeWillExpire` cycle at a time — there's no
|
||||||
/// real concurrent access to `contentHandler`/`bestAttemptContent` to guard
|
/// 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()
|
let mutableContent = (request.content.mutableCopy() as? UNMutableNotificationContent) ?? UNMutableNotificationContent()
|
||||||
bestAttemptContent = mutableContent
|
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 {
|
let imageURL = URL(string: imageURLString) else {
|
||||||
deliver()
|
deliver()
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ struct ContentView: View {
|
|||||||
@State private var sessionExpiredObserver: NSObjectProtocol?
|
@State private var sessionExpiredObserver: NSObjectProtocol?
|
||||||
@State var cartResetObserver: Any?
|
@State var cartResetObserver: Any?
|
||||||
@State var appResumeObserver: Any?
|
@State var appResumeObserver: Any?
|
||||||
@State var pushOrderTapObserver: Any?
|
@State var pushDeepLinkObserver: Any?
|
||||||
@StateObject var snackbarCenter = SnackbarCenter.shared
|
@StateObject var snackbarCenter = SnackbarCenter.shared
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@@ -91,13 +91,13 @@ struct ContentView: View {
|
|||||||
attachCartResetObserverIfNeeded()
|
attachCartResetObserverIfNeeded()
|
||||||
attachAppResumeObserverIfNeeded()
|
attachAppResumeObserverIfNeeded()
|
||||||
attachSessionExpiredObserverIfNeeded()
|
attachSessionExpiredObserverIfNeeded()
|
||||||
attachPushOrderTapObserverIfNeeded()
|
attachPushDeepLinkObserverIfNeeded()
|
||||||
}
|
}
|
||||||
.onDisappear {
|
.onDisappear {
|
||||||
detachCartResetObserver()
|
detachCartResetObserver()
|
||||||
detachAppResumeObserver()
|
detachAppResumeObserver()
|
||||||
detachSessionExpiredObserver()
|
detachSessionExpiredObserver()
|
||||||
detachPushOrderTapObserver()
|
detachPushDeepLinkObserver()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -244,36 +244,50 @@ struct ContentView: View {
|
|||||||
self.appResumeObserver = nil
|
self.appResumeObserver = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
/// §6 of the push notifications guide — an `order_status` push tap
|
/// §6 of the push notifications guide, generalized: any tapped push that
|
||||||
/// (reported by `PushNotificationCoordinator`) routes here to the
|
/// resolves to a `DeepLinkDestination` (reported by
|
||||||
/// Profile tab's Orders list, pre-targeted at that order.
|
/// `PushNotificationCoordinator`) lands here. Adding a future promo/coupon
|
||||||
private func attachPushOrderTapObserverIfNeeded() {
|
/// screen means adding one case to `route(to:)` below — this observer,
|
||||||
guard pushOrderTapObserver == nil else { return }
|
/// the notification name, and the AppState plumbing for it stay put.
|
||||||
pushOrderTapObserver = NotificationCenter.default.addObserver(
|
private func attachPushDeepLinkObserverIfNeeded() {
|
||||||
forName: .pushTappedOrderStatus,
|
guard pushDeepLinkObserver == nil else { return }
|
||||||
|
pushDeepLinkObserver = NotificationCenter.default.addObserver(
|
||||||
|
forName: .pushDeepLinkReceived,
|
||||||
object: nil,
|
object: nil,
|
||||||
queue: nil
|
queue: nil
|
||||||
) { notification in
|
) { notification in
|
||||||
let orderId = notification.userInfo?["orderId"] as? String
|
guard let destination = notification.userInfo?["destination"] as? DeepLinkDestination else { return }
|
||||||
let shortId = notification.userInfo?["shortId"] as? String
|
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
guard root == .main, let orderId else { return }
|
route(to: destination)
|
||||||
appState.pendingOrderDeepLink = OrderRouteContext(
|
|
||||||
orderId: orderId,
|
|
||||||
shortId: shortId,
|
|
||||||
paymentMethod: nil,
|
|
||||||
total: nil,
|
|
||||||
intent: .auto
|
|
||||||
)
|
|
||||||
selectedTab = .profile
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func detachPushOrderTapObserver() {
|
private func detachPushDeepLinkObserver() {
|
||||||
guard let pushOrderTapObserver else { return }
|
guard let pushDeepLinkObserver else { return }
|
||||||
NotificationCenter.default.removeObserver(pushOrderTapObserver)
|
NotificationCenter.default.removeObserver(pushDeepLinkObserver)
|
||||||
self.pushOrderTapObserver = nil
|
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
|
@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
|
case notDetermined
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Posted when the user taps an `order_status` push (§6) so `ContentView`
|
/// Posted when a tapped push resolves to a navigable `DeepLinkDestination`
|
||||||
/// can route to that order without this service depending on `AppState`.
|
/// (§6) so `ContentView` can route without this service depending on
|
||||||
|
/// `AppState`. One name for every destination, present and future — see
|
||||||
|
/// `DeepLinkDestination`.
|
||||||
extension Notification.Name {
|
extension Notification.Name {
|
||||||
static let pushTappedOrderStatus = Notification.Name("pushTappedOrderStatus")
|
static let pushDeepLinkReceived = Notification.Name("pushDeepLinkReceived")
|
||||||
}
|
}
|
||||||
|
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
@@ -153,26 +155,21 @@ final class PushNotificationCoordinator: NSObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// §6 — routes on the tapped push's `data` payload. `order_status` gets
|
/// §6 — routes on the tapped push's `data` payload. Campaign-open
|
||||||
/// forwarded to `ContentView` via `NotificationCenter` (this service has
|
/// reporting (§6a, a side effect, not a navigation target) is decided
|
||||||
/// no `AppState` binding of its own); `campaign` self-reports its open.
|
/// 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]) {
|
fileprivate func handleTap(userInfo: [AnyHashable: Any]) {
|
||||||
guard let type = userInfo["type"] as? String else { return }
|
if let type = userInfo["type"] as? String, type == "campaign",
|
||||||
switch type {
|
let campaignId = userInfo["campaignId"] as? String {
|
||||||
case "campaign":
|
|
||||||
guard let campaignId = userInfo["campaignId"] as? String else { return }
|
|
||||||
Task { await reportCampaignOpened(campaignId: campaignId) }
|
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