Skip to content

ADR — gym-app push-notification routing by type & entity-id

ADR — gym-app push-notification routing by type & entity-id

Section titled “ADR — gym-app push-notification routing by type & entity-id”

Spec: specs/gym-app-push-notification-routing-by-type.md ClickUp: 869dk67px Status: Accepted Date: 2026-06-06

The gym_app push tap handler was a single hardcoded redirect:

widget.pushService.onNotificationTap = () => router.go('/notifications');

Regardless of FCM payload type, every tap landed the user on the generic notifications list — outside the ShellRoute, so without the bottom nav. The tapped notification was not auto-opened, just sitting in the list as one row of many. Users had to hunt for an “X” to escape, and booking confirmations / pass purchases / payment results all looked identical from a navigation standpoint.

Meanwhile the backend already enriches every push with type plus entity-ids (bookingId, sessionId, customerPassId, companyId, passId, …) — see tktspace-backend/libs/features/.../notify({...}) call sites. The mobile side discarded everything except notificationId and the same notificationId was stashed in pendingNotificationId but never read by any consumer (dead code in three places).

D1. Routing decision lives in a pure dispatcher in packages/notifications.

Section titled “D1. Routing decision lives in a pure dispatcher in packages/notifications.”

notificationRoute(Map<String, dynamic>? data) -> String? is a side-effect-free function that maps payload → canonical in-app route path, or null if unknown / missing required entity-id. Co-locating the mapping table with the function keeps any new push type a single-file edit on the mobile side.

Why a function and not an enum / service: the mapping is data, not behaviour. A function is the cheapest unit of test (no providers, no widgets). A future per-brand override path is a one-line callback on the push service.

D2. NotificationsService carries the full payload, not just the id.

Section titled “D2. NotificationsService carries the full payload, not just the id.”

A new pendingData: Map<String, dynamic>? lives next to the existing pendingNotificationId. All three callers in PushNotificationsService (onMessageOpenedApp, native MethodChannel, getInitialMessage) write both. A clearPending() helper sweeps both in one call so consumers can’t drift.

The existing dead pendingNotificationId writes (3 paths) now have a real consumer: NotificationsPage auto-opens the matching modal on the fallback route after load() settles.

D3. Native MethodChannel widens from String to Map.

Section titled “D3. Native MethodChannel widens from String to Map.”

iOS AppDelegate.swift previously sent only notificationId over the tktspace/push onNotificationTap channel. It now forwards the entire APNS userInfo dictionary (String-keyed entries only — Flutter’s MethodChannel can’t carry NSObject keys). The Flutter handler accepts both shapes for backwards-compat: legacy native builds that still send a String land on the /notifications fallback by id; new builds get the type-aware route.

Android has no custom MethodChannel — Firebase plugin handles cold-start taps via getInitialMessage. The widened MethodChannel is iOS-only.

D4. /notifications moves inside the ShellRoute.

Section titled “D4. /notifications moves inside the ShellRoute.”

Currently a top-level route, outside the bottom-nav shell. Becomes a sibling of /home/main, /home/wallet, etc. The visible result: tapping a push that falls through to the list keeps the tab bar visible — the user is not stuck on an unfamiliar standalone screen. The leading “X” button on NotificationsPage is removed; navigation away uses the tabs or system back.

D5. Booking-family types intentionally fall through to the list.

Section titled “D5. Booking-family types intentionally fall through to the list.”

BOOKING_CREATED, BOOKING_CANCELLED, REFUND_REQUESTED, REFUND_AUTO_APPROVED return null from the dispatcher today. Reason: there is no dedicated booking-detail screen yet. The auto-opened modal on /notifications carries the full title + body of the tapped notification, which is good enough for a “you got booked / refund processed” message. When a real booking-detail screen ships, the dispatcher is the one-line change.

BROADCAST_MESSAGE and any unknown type follow the same fallback path.

Positive:

  • Every push type with a meaningful target gets a one-tap landing.
  • The “stuck on a stand-alone screen” UX is eliminated globally.
  • Dead pendingNotificationId writes finally have a consumer.
  • New push type = new switch arm + entry in the table = one PR, one file.

Negative / accepted trade-offs:

  • No analytics on tap-routing outcomes in v1 (notification_tap_routed event deferred — follow-up if needed).
  • Booking pushes still land on the list until a booking-detail screen exists.
  • tickets_app is not addressed by this work (no PushNotificationsService instantiated there at all — separate ticket).
  • iOS MethodChannel change requires a native rebuild for the type-routing path; until then iOS cold-launch falls back to id-only routing (which already works via the auto-open modal).

Alternative: per-controller NavigationService injected into PushNotificationsService. Rejected — couples the push package to gym_app’s router. The pure dispatcher + simple callback is the lightest seam and keeps the package brand-agnostic.

Alternative: regenerate routes from a backend-shipped manifest. Rejected — overkill for ~10 push types that change at the pace of new features, not weeks. The compile-time switch wins on legibility and keeps schema changes in one file pair (backend notify() call sites

  • mobile notification_routes.dart).

Alternative: keep /notifications as a top-level route and add a home button. Rejected — fixes one symptom (escape) but not the others (no tab context, doesn’t fix navigation back to where the user was). Moving inside the shell is the single right answer.