/// Session / screen-journey / friction tracker for the Flutter estate. /// /// Ports the WordPress sites' SessionJourney module (help4bis-siteanalytics) /// to Flutter, per plan `ancient-petting-hejlsberg.md` §3. One file, written /// once and copied into each in-scope app's `lib/core/analytics/` with the /// [sessionJourneyAppKey] differing per copy (no shared pub workspace exists /// across these repos, so a copy-with-version-header is the right-sized /// solution). This copy is wired for M8Chat (`m8chat.au-app`), rolled out /// after the GardenBuddy pilot (Phase 2) per the plan's §5 rollout order. /// /// Talks to `stats.help4bis.com/track.php` v2 via its `action`-based JSON /// actions, NOT the legacy `{s,u,w,r}` pageview beacon (M8Chat currently has /// no such beacon in `web/index.html` at all — out of scope here, per the /// plan this task covers SessionJourney only). /// /// ## Where this copy differs from GardenBuddy's (read before copying again) /// /// **Persistence.** GardenBuddy's copy depends on `core/offline/offline_cache.dart`, /// a shared module this repo does not have (verified by reading this repo /// before writing this file — no `shared_preferences`, no existing /// localStorage abstraction; only `flutter_secure_storage`, async-only and /// reserved for the one real secret this app persists, the Matrix device /// id). This copy uses a small sibling module, `sj_local_store.dart` /// (+ `_stub`/`_web` platform split), hand-rolled the same way GardenBuddy's /// own store is, scoped to SessionJourney's own keys only. See that file's /// header for the full reasoning. /// /// **Routing / screen naming.** M8Chat uses GoRouter (`MaterialApp.router`, /// `lib/app/router.dart`), not GardenBuddy's Navigator 1.0 + IndexedStack /// tabs, and its routes are plain `path:`-based `GoRoute`s with no explicit /// `name:`. Unlike GardenBuddy's copy (which had to document an /// `"unnamed_route"` gap), M8Chat gets real per-screen names for free: /// go_router 17.2.0's page builder (`builder.dart`) sets /// `name: state.name ?? state.path`, so `route.settings.name` resolves to /// the route's path (`/rooms`, `/rooms/:roomId`, `/profile`, `/spaces`, /// `/jitsi`, `/welcome`, `/login`, `/calls/:roomId`) — verified by reading /// the installed go_router source, not assumed. So /// [SessionJourneyNavigatorObserver] alone (registered on `GoRouter`'s /// `observers:`) covers every screen in this app; there is no per-tab /// `trackScreen` call to add anywhere, unlike GardenBuddy's `HomeShell`. /// /// **Error-handler chaining.** `lib/main.dart` already sets both /// `FlutterError.onError` and `PlatformDispatcher.instance.onError` to /// `debugPrint`-only handlers. [SessionJourney.init] chains to whatever is /// already assigned at the point it runs (unconditionally, same as /// GardenBuddy's copy) — the caller in `main()` must call [SessionJourney.init] /// *after* those two assignments so this module captures them as /// `previousFlutterOnError`/`previousPlatformOnError` rather than being /// captured itself and silently dropped. /// /// Everything else (identity, sliding session window, heartbeat cadence, /// rage-tap clustering, queue/flush shape, server contract) is unchanged /// from the GardenBuddy copy — see that file's header for the full design /// rationale, including the documented `flutter_visitors_v2.session_count` /// accounting gap in track.php v2 (re-sending `session_enter` per screen /// change is the only way the deployed server creates a /// `flutter_screen_views` row per screen; this is a Phase-1 backend gap, /// not a client bug, and applies identically here). /// /// Version: 1.0.1 | Created: 2026-09-25 | Updated: 2026-09-25 /// v1.0.1: trackScreen() resolved visitorId/sessionId into locals /// (resolvedVisitorId/resolvedSessionId) BEFORE assigning /// _currentScreenName/_currentStepUid, so the sessionId getter's /// mint-on-cold-start side effect (which nulls those fields) can no /// longer race the same-screen idempotency check and cause a duplicate /// session_enter right after cold start. library; import 'dart:async'; import 'dart:convert'; import 'dart:math'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:http/http.dart' as http; import 'sj_local_store.dart'; /// The one line that differs (besides the header comment) between per-app /// copies of this file. Must match an entry in track.php v2's /// `$allowed_sites`, confirmed by reading /// `sites/stats.help4bis.com/track.php` on brisbane01 directly before this /// file was written: `m8chat.au-app` / origin `https://app2.m8chat.au`. const String sessionJourneyAppKey = 'm8chat.au-app'; const String _trackOrigin = 'https://stats.help4bis.com'; const String _trackPath = '/track.php'; /// Sliding session window: a gap shorter than this while backgrounded /// resumes the same session; longer, or no persisted timestamp at all /// (cold start with nothing recent), starts a fresh one. const Duration sessionTimeout = Duration(minutes: 30); /// Flush triggers: whichever comes first. const int flushBatchSize = 25; const Duration flushInterval = Duration(seconds: 10); /// How often an open screen sends a heartbeat while it stays the active one. const Duration heartbeatInterval = Duration(seconds: 15); /// Oldest-dropped cap on the durable outbox, generous for a normal session. const int maxQueueLength = 300; const Duration _requestTimeout = Duration(seconds: 6); /// Rage-tap clustering: >= this many PointerDownEvents within [rageTapWindow] /// and [rageTapProximityPx] of each other counts as one rage-tap cluster. const int rageTapThreshold = 4; const Duration rageTapWindow = Duration(milliseconds: 1500); const double rageTapProximityPx = 40; /// Engagement event kinds the server allowlists (see track.php's /// `$kind_allowlist`). Kept here so a bad call fails fast, client side. enum EngagementKind { rageTap, error, elementSeen, custom } extension on EngagementKind { String get wireValue => switch (this) { EngagementKind.rageTap => 'rage_tap', EngagementKind.error => 'error', EngagementKind.elementSeen => 'element_seen', EngagementKind.custom => 'custom', }; } /// A fresh UUIDv4 (RFC 4122 §4.4), generated locally. No `uuid` package /// dependency, and track.php's `is_uuid_v4()` regex requires the real shape /// (version nibble `4`, variant nibble `8-b`). String newUuidV4({Random? random}) { final rnd = random ?? Random.secure(); final bytes = List.generate(16, (_) => rnd.nextInt(256)); bytes[6] = (bytes[6] & 0x0f) | 0x40; // version 4 bytes[8] = (bytes[8] & 0x3f) | 0x80; // variant 10xx final hex = bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join(); return '${hex.substring(0, 8)}-${hex.substring(8, 12)}-' '${hex.substring(12, 16)}-${hex.substring(16, 20)}-' '${hex.substring(20)}'; } /// One queued request to track.php: an action name plus its JSON body /// (already including `action`/`appKey`/etc.), persisted verbatim. @immutable class _QueuedRequest { const _QueuedRequest({required this.body, required this.queuedAt}); final Map body; final DateTime queuedAt; Map toJson() => { 'body': body, 'queued_at': queuedAt.toIso8601String(), }; static _QueuedRequest? fromJson(Object? json) { if (json is! Map) { return null; } final body = json['body']; if (body is! Map) { return null; } final queuedAt = DateTime.tryParse('${json['queued_at']}'); if (queuedAt == null) { return null; } return _QueuedRequest( body: Map.from(body), queuedAt: queuedAt, ); } } /// One pending pointer-down, kept only long enough to cluster it. class _PendingTap { _PendingTap(this.at, this.position); final DateTime at; final Offset position; } /// Session / journey / friction tracker. One instance per app run, reached /// through [SessionJourney.instance] after [SessionJourney.init] runs once /// in `main()`, before `runApp`. class SessionJourney extends WidgetsBindingObserver { SessionJourney._({ required this.appVersion, http.Client? client, SjLocalStore? cache, DateTime Function()? now, Duration flushEvery = flushInterval, Duration heartbeatEvery = heartbeatInterval, }) : _client = client ?? http.Client(), _cache = cache ?? SjLocalStore(), _now = now ?? DateTime.now, _flushEvery = flushEvery, _heartbeatEvery = heartbeatEvery; /// The app's own version string (from `pubspec.yaml`, passed in by /// `main()` and kept in sync by hand, same manual-sync convention this /// repo already accepts for version-header comments). final String appVersion; final http.Client _client; final SjLocalStore _cache; final DateTime Function() _now; final Duration _flushEvery; final Duration _heartbeatEvery; static SessionJourney? _instance; /// The running instance. Throws if read before [init] has run; every /// call site in this app runs after `main()`'s [init] call, so that is /// a programming error, not a runtime condition to handle gracefully. static SessionJourney get instance { final i = _instance; if (i == null) { throw StateError('SessionJourney.init() has not run yet.'); } return i; } /// The running instance, or `null` if [init] has not run — for wiring /// code exercised outside `main()`'s normal boot order (widget tests). static SessionJourney? get instanceOrNull => _instance; /// Boots the singleton, chains the global error handlers, and starts the /// flush loop. Call once from `main()`, before `runApp` — and, in this /// app specifically, *after* `main()`'s existing `FlutterError.onError` /// and `PlatformDispatcher.instance.onError` assignments, so this chains /// to them rather than being silently overwritten itself. static SessionJourney init({ required String appVersion, http.Client? client, SjLocalStore? cache, DateTime Function()? now, }) { final journey = SessionJourney._( appVersion: appVersion, client: client, cache: cache, now: now, ); _instance = journey; final previousFlutterOnError = FlutterError.onError; FlutterError.onError = (FlutterErrorDetails details) { journey.recordError(details.exceptionAsString()); previousFlutterOnError?.call(details); }; final previousPlatformOnError = PlatformDispatcher.instance.onError; PlatformDispatcher.instance.onError = (Object error, StackTrace stack) { journey.recordError(error.toString()); return previousPlatformOnError?.call(error, stack) ?? false; }; journey.start(); return journey; } Timer? _flushTimer; Timer? _heartbeatTimer; Future _inFlightFlush = Future.value(); bool _flushPending = false; // --- Identity ------------------------------------------------------------ String? _visitorId; /// Persistent, app-scoped visitor id: generated once, read back every /// launch. Never rotates. String get visitorId { final cached = _visitorId; if (cached != null) { return cached; } final stored = _cache.readJson(SjStoreKeys.visitorId); if (stored is String && stored.isNotEmpty) { _visitorId = stored; return stored; } final fresh = newUuidV4(); _cache.writeJson(SjStoreKeys.visitorId, fresh); _visitorId = fresh; return fresh; } String? _sessionId; DateTime? _sessionLastActive; bool _sessionIdentityLoaded = false; void _loadSessionIdentityOnce() { if (_sessionIdentityLoaded) { return; } _sessionIdentityLoaded = true; final storedId = _cache.readJson(SjStoreKeys.sessionId); final storedAt = _cache.readJson(SjStoreKeys.sessionLastActive); if (storedId is String && storedId.isNotEmpty && storedAt is String) { final parsed = DateTime.tryParse(storedAt); if (parsed != null && _now().difference(parsed) <= sessionTimeout) { _sessionId = storedId; _sessionLastActive = parsed; } } } /// The current session id, minting (and persisting) a fresh one when the /// sliding 30-minute window has lapsed, or on a cold start with no usable /// persisted timestamp. Touching this always refreshes the "last active" /// timestamp, so a session only actually expires after real inactivity. String get sessionId { _loadSessionIdentityOnce(); final last = _sessionLastActive; final now = _now(); if (_sessionId == null || last == null || now.difference(last) > sessionTimeout) { _sessionId = newUuidV4(); _cache.writeJson(SjStoreKeys.sessionId, _sessionId); // A brand new session means whatever screen is "current" belongs to // the old session; the next trackScreen() call re-enters it fresh. _currentScreenName = null; _currentStepUid = null; } _sessionLastActive = now; _cache.writeJson(SjStoreKeys.sessionLastActive, now.toIso8601String()); return _sessionId!; } // --- Lifecycle ------------------------------------------------------------- /// Idempotent: replays any leftover outbox from a previous run before /// anything new queues, then starts the periodic flush timer. void start() { WidgetsBinding.instance.addObserver(this); unawaited(flush()); _flushTimer ??= Timer.periodic(_flushEvery, (_) => unawaited(flush())); } void dispose() { WidgetsBinding.instance.removeObserver(this); _flushTimer?.cancel(); _flushTimer = null; _heartbeatTimer?.cancel(); _heartbeatTimer = null; } @override void didChangeAppLifecycleState(AppLifecycleState state) { if (state == AppLifecycleState.paused || state == AppLifecycleState.detached) { _leaveCurrentScreen(); unawaited(flush()); } } // --- Screen tracking ------------------------------------------------------- String? _currentScreenName; String? _currentStepUid; DateTime? _currentScreenEnteredAt; /// Enters [screenName] as the current screen: finalises the previous /// screen (a `screen_leave` with its accumulated dwell/active seconds, if /// one was open) and sends `session_enter` for the new one — the only /// action the deployed track.php actually inserts a `flutter_screen_views` /// row from. /// /// Idempotent for repeated calls with the same [screenName]. void trackScreen(String screenName) { if (screenName.isEmpty || screenName == _currentScreenName) { return; } _leaveCurrentScreen(); // Resolve visitorId/sessionId BEFORE assigning _currentScreenName: // sessionId's getter mints (and persists) a fresh session on a lapsed // or cold window, and as a documented side effect of that mint it // nulls _currentScreenName/_currentStepUid so the next trackScreen() // re-enters cleanly under the new session. Reading it after already // setting _currentScreenName below would immediately undo that // assignment on every cold start, breaking this method's own // same-screen idempotency and causing a duplicate session_enter on // the very next call for the same screen. Found and fixed 2026-09-25. final resolvedVisitorId = visitorId; final resolvedSessionId = sessionId; final stepUid = newUuidV4(); _currentScreenName = screenName; _currentStepUid = stepUid; _currentScreenEnteredAt = _now(); _startHeartbeatTimer(); _enqueue({ 'action': 'session_enter', 'appKey': sessionJourneyAppKey, 'visitorId': resolvedVisitorId, 'sessionId': resolvedSessionId, 'stepUid': stepUid, 'platform': kIsWeb ? 'web' : 'unknown', 'appVersion': appVersion, 'referrer': null, 'screen': screenName, }); } void _startHeartbeatTimer() { _heartbeatTimer?.cancel(); _heartbeatTimer = Timer.periodic(_heartbeatEvery, (_) => _heartbeat()); } /// No idle/focus detection wired in v1: `activeSeconds` is treated as /// equal to elapsed dwell time on the screen, a deliberate simplification. void _heartbeat() { final stepUid = _currentStepUid; final enteredAt = _currentScreenEnteredAt; if (stepUid == null || enteredAt == null) { return; } final elapsed = _now().difference(enteredAt).inSeconds; _enqueue({ 'action': 'heartbeat', 'appKey': sessionJourneyAppKey, 'sessionId': sessionId, 'stepUid': stepUid, 'dwellSeconds': elapsed, 'activeSeconds': elapsed, 'scrollPct': null, }); } void _leaveCurrentScreen() { _heartbeatTimer?.cancel(); _heartbeatTimer = null; final stepUid = _currentStepUid; final enteredAt = _currentScreenEnteredAt; if (stepUid == null || enteredAt == null) { return; } final elapsed = _now().difference(enteredAt).inSeconds; _enqueue({ 'action': 'screen_leave', 'appKey': sessionJourneyAppKey, 'sessionId': sessionId, 'stepUid': stepUid, 'dwellSeconds': elapsed, 'activeSeconds': elapsed, 'scrollPct': null, }); _currentStepUid = null; _currentScreenEnteredAt = null; } // --- Engagement ------------------------------------------------------------ /// Queues one `engagement` event. Each call is its own request (`events` /// as a single-item array) rather than coalescing several into one POST. void _recordEngagement( EngagementKind kind, { String? label, String? context, String? value, }) { _enqueue({ 'action': 'engagement', 'appKey': sessionJourneyAppKey, 'sessionId': sessionId, 'visitorId': visitorId, 'events': [ { 'stepUid': _currentStepUid, 'kind': kind.wireValue, 'label': label, 'context': context, 'value': value, 'occurredAt': _now().toIso8601String(), }, ], }); } /// One-off business events an app instruments itself /// (`SessionJourney.instance.trackCustomEvent(...)`). void trackCustomEvent(String label, String context, {num? value}) { if (label.isEmpty) { return; } _recordEngagement( EngagementKind.custom, label: label, context: context, value: value?.toString(), ); } /// Reported by [FlutterError.onError] / `PlatformDispatcher`'s handler /// (wired in [init], chained after `main.dart`'s existing debugPrint /// handlers), and available for a manual `try`/`catch` to call directly. /// Truncated: never send a huge stack dump as `context`. void recordError(String message) { const maxLength = 300; final truncated = message.length > maxLength ? '${message.substring(0, maxLength)}…' : message; _recordEngagement( EngagementKind.error, label: _currentScreenName ?? 'unknown_screen', context: truncated, ); } // --- Rage-tap detection ------------------------------------------------ final List<_PendingTap> _recentTaps = []; /// Called from [SessionJourneyGestureLayer]'s root `Listener` for every /// `PointerDownEvent`. Clusters by time ([rageTapWindow]) and proximity /// ([rageTapProximityPx]); firing a `rage_tap` engagement event resets the /// cluster so the same burst cannot fire twice. void recordTap(Offset position) { final now = _now(); _recentTaps.removeWhere((t) => now.difference(t.at) > rageTapWindow); _recentTaps.add(_PendingTap(now, position)); final cluster = _recentTaps .where((t) => (t.position - position).distance <= rageTapProximityPx) .toList(); if (cluster.length >= rageTapThreshold) { final screen = _currentScreenName ?? 'unknown_screen'; _recordEngagement( EngagementKind.rageTap, label: screen, context: '${cluster.length} taps near ' '(${position.dx.round()},${position.dy.round()}) on $screen', ); _recentTaps.clear(); } } // --- Queue / flush ----------------------------------------------------- Uri get _trackUri => Uri.parse('$_trackOrigin$_trackPath'); List<_QueuedRequest> _readQueue() { final data = _cache.readJson(SjStoreKeys.queue); if (data is! List) { return []; } return data .map(_QueuedRequest.fromJson) .whereType<_QueuedRequest>() .toList(); } void _writeQueue(List<_QueuedRequest> queue) { _cache.writeJson(SjStoreKeys.queue, queue.map((q) => q.toJson()).toList()); } /// The queue as it stands right now. Visible for tests. List> get queue => _readQueue().map((q) => q.body).toList(); void _enqueue(Map body) { final queue = _readQueue() ..add(_QueuedRequest(body: body, queuedAt: _now())); while (queue.length > maxQueueLength) { queue.removeAt(0); // Oldest dropped first. } _writeQueue(queue); if (queue.length >= flushBatchSize) { unawaited(flush()); } else { _flushPending = true; unawaited(flush()); } } /// Drains the persisted queue, one request at a time. Reentrant-safe: a /// call while a sweep is already running shares that sweep's future /// rather than racing it. Future flush() { if (_flushPending == false && _readQueue().isEmpty) { return Future.value(); } _flushPending = false; final chained = _inFlightFlush.then((_) => _runFlush()); _inFlightFlush = chained; return chained; } Future _runFlush() async { while (true) { final current = _readQueue(); if (current.isEmpty) { return; } final request = current.first; final sent = await _post(request.body); if (!sent) { return; // Network failure: stop this sweep, retry next timer tick. } final remaining = _readQueue(); if (remaining.isEmpty) { return; } _writeQueue(remaining.sublist(1)); } } /// True on any HTTP response (track.php always answers 200, even for a /// payload it silently drops). False only on a network-level failure, /// which is the one case worth retrying. Future _post(Map body) async { try { final response = await _client .post( _trackUri, headers: const {'Content-Type': 'application/json'}, body: jsonEncode(body), ) .timeout(_requestTimeout); return response.statusCode == 200; } on Object { return false; } } } /// `NavigatorObserver` for GoRouter navigation. Registered on `GoRouter`'s /// `observers: []` in `lib/app/router.dart`. See this file's header for why /// M8Chat's routes resolve to real path-based names here (unlike /// GardenBuddy's `"unnamed_route"` gap). class SessionJourneyNavigatorObserver extends NavigatorObserver { SessionJourneyNavigatorObserver([SessionJourney? journey]) : _journey = journey; final SessionJourney? _journey; /// `null` when this observer fires before [SessionJourney.init] has run /// (widget tests). See [SessionJourney.instanceOrNull]. SessionJourney? get _instance => _journey ?? SessionJourney.instanceOrNull; String _nameOf(Route route) => route.settings.name ?? 'unnamed_route'; @override void didPush(Route route, Route? previousRoute) { if (route is PageRoute) { _instance?.trackScreen(_nameOf(route)); } } @override void didPop(Route route, Route? previousRoute) { if (previousRoute is PageRoute) { _instance?.trackScreen(_nameOf(previousRoute)); } } @override void didReplace({Route? newRoute, Route? oldRoute}) { if (newRoute is PageRoute) { _instance?.trackScreen(_nameOf(newRoute)); } } } /// Root-level rage-tap detector. Wrap the whole app with this once, above /// `MaterialApp.router`, in `main()`. A bare `Listener`, not a /// `GestureDetector`: this must see every `PointerDownEvent` in the tree /// without competing for gesture-arena ownership with the app's own gestures /// (chat swipe-to-reply, panic hold-press, etc.), so it only reads /// pointer-down positions and never claims or consumes the event. class SessionJourneyGestureLayer extends StatelessWidget { const SessionJourneyGestureLayer({required this.child, super.key}); final Widget child; @override Widget build(BuildContext context) { return Listener( behavior: HitTestBehavior.translucent, onPointerDown: (PointerDownEvent event) { SessionJourney.instanceOrNull?.recordTap(event.position); }, child: child, ); } }