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
Context
Section titled “Context”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).
Decision
Section titled “Decision”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.
Consequences
Section titled “Consequences”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
pendingNotificationIdwrites 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_routedevent deferred — follow-up if needed). - Booking pushes still land on the list until a booking-detail screen exists.
tickets_appis not addressed by this work (noPushNotificationsServiceinstantiated 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).
Alternatives considered
Section titled “Alternatives considered”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.