From c4d69985950bb03cfa40c108d5845796508c001e Mon Sep 17 00:00:00 2001 From: Daniel Arantes Loverde Date: Wed, 5 Aug 2026 15:19:18 -0300 Subject: [PATCH] [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. --- .../NotificationService.swift | 6 +- Sources/PediFoods/ContentView.swift | 64 +++++++++++-------- .../Coordinators/DeepLinkDestination.swift | 49 ++++++++++++++ .../PushNotificationCoordinator.swift | 37 +++++------ 4 files changed, 109 insertions(+), 47 deletions(-) create mode 100644 Sources/PediFoods/Coordinators/DeepLinkDestination.swift diff --git a/Darwin/NotificationServiceExtension/NotificationService.swift b/Darwin/NotificationServiceExtension/NotificationService.swift index 7576d91..ad5c69a 100644 --- a/Darwin/NotificationServiceExtension/NotificationService.swift +++ b/Darwin/NotificationServiceExtension/NotificationService.swift @@ -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 diff --git a/Sources/PediFoods/ContentView.swift b/Sources/PediFoods/ContentView.swift index f92f42d..c624263 100644 --- a/Sources/PediFoods/ContentView.swift +++ b/Sources/PediFoods/ContentView.swift @@ -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 diff --git a/Sources/PediFoods/Coordinators/DeepLinkDestination.swift b/Sources/PediFoods/Coordinators/DeepLinkDestination.swift new file mode 100644 index 0000000..1a1b489 --- /dev/null +++ b/Sources/PediFoods/Coordinators/DeepLinkDestination.swift @@ -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) + } +} diff --git a/Sources/PediFoods/Services/PushNotificationCoordinator.swift b/Sources/PediFoods/Services/PushNotificationCoordinator.swift index 5404455..c319feb 100644 --- a/Sources/PediFoods/Services/PushNotificationCoordinator.swift +++ b/Sources/PediFoods/Services/PushNotificationCoordinator.swift @@ -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]) } }