From 729822c97ec4af96b31b747b7f0c60d3ed7c2149 Mon Sep 17 00:00:00 2001 From: help4bis Date: Fri, 25 Sep 2026 14:34:18 +1000 Subject: [PATCH] feat(analytics): port SessionJourney tracking from GardenBuddy (v1.12.0+16) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rolls the session/journey/friction tracking module out to M8Chat app2, per plan ancient-petting-hejlsberg.md §5 step 4. Same pattern as GardenBuddy's same-day port: SessionJourney.init() wired after WidgetsFlutterBinding. ensureInitialized() and after the existing FlutterError.onError / PlatformDispatcher.instance.onError handlers, which it chains rather than replaces. SessionJourneyNavigatorObserver registered on the GoRouter instance; SessionJourneyGestureLayer wraps MaterialApp.router for rage-tap detection. This repo has no shared offline-cache module like GardenBuddy's core/offline/, so persistence is a small scoped sj_local_store.dart using the package:web dependency already present in the repo (localStorage on web, in-memory fallback for the test VM), rather than adding a new package. app_key m8chat.au-app was reserved today in track.php v2.0.1's allowed_sites/allowed_origins for https://app2.m8chat.au, confirmed by grep before use. Verified live: build deployed to app2.m8chat.au (md5-matched), Playwright navigation produced real session/visitor/screen_view rows in admin_flutterstats with app_key=m8chat.au-app, nginx logs show no 5xx and no real visitor served a broken build during the test window. --- lib/app/app.dart | 29 +- lib/app/router.dart | 12 +- lib/core/analytics/session_journey.dart | 668 ++++++++++++++++++++ lib/core/analytics/sj_local_store.dart | 103 +++ lib/core/analytics/sj_local_store_stub.dart | 8 + lib/core/analytics/sj_local_store_web.dart | 26 + lib/main.dart | 18 +- pubspec.yaml | 2 +- 8 files changed, 846 insertions(+), 20 deletions(-) create mode 100644 lib/core/analytics/session_journey.dart create mode 100644 lib/core/analytics/sj_local_store.dart create mode 100644 lib/core/analytics/sj_local_store_stub.dart create mode 100644 lib/core/analytics/sj_local_store_web.dart diff --git a/lib/app/app.dart b/lib/app/app.dart index 0d0fe30..d1d3416 100644 --- a/lib/app/app.dart +++ b/lib/app/app.dart @@ -1,11 +1,16 @@ -// Version: 1.1.0 | Created: 2026-04-01 | Updated: 2026-07-04 +// Version: 1.2.0 | Created: 2026-04-01 | Updated: 2026-09-25 // Root MaterialApp widget. Wires together theme + router + incoming-call // overlay (v1.1.0 — the overlay existed but was never mounted, so incoming // calls showed nothing). +// v1.2.0: wrapped in SessionJourneyGestureLayer for rage-tap detection +// (plan ancient-petting-hejlsberg.md §3/§5). Placed above MaterialApp.router +// so it sees every PointerDownEvent in the tree without competing for +// gesture-arena ownership with in-app gestures. import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../core/analytics/session_journey.dart'; import '../features/calls/presentation/incoming_call_overlay.dart'; import 'router.dart'; import 'theme.dart'; @@ -21,16 +26,18 @@ class M8ChatApp extends ConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final router = ref.watch(routerProvider); - return MaterialApp.router( - title: 'M8Chat', - theme: buildLightTheme(), - darkTheme: buildDarkTheme(), - // Default to dark — M8Chat is a dark-first chat app. - themeMode: ThemeMode.system, - routerConfig: router, - debugShowCheckedModeBanner: false, - builder: (context, child) => - IncomingCallOverlayHost(child: child ?? const SizedBox.shrink()), + return SessionJourneyGestureLayer( + child: MaterialApp.router( + title: 'M8Chat', + theme: buildLightTheme(), + darkTheme: buildDarkTheme(), + // Default to dark — M8Chat is a dark-first chat app. + themeMode: ThemeMode.system, + routerConfig: router, + debugShowCheckedModeBanner: false, + builder: (context, child) => + IncomingCallOverlayHost(child: child ?? const SizedBox.shrink()), + ), ); } } diff --git a/lib/app/router.dart b/lib/app/router.dart index 2e52a18..eb47128 100644 --- a/lib/app/router.dart +++ b/lib/app/router.dart @@ -1,12 +1,15 @@ -// Version: 1.1.0 | Created: 2026-04-01 | Updated: 2026-04-05 +// Version: 1.2.0 | Created: 2026-04-01 | Updated: 2026-09-25 // All route definitions in one place. // GoRouter is created ONCE (keepAlive) and re-evaluates redirects via // refreshListenable — avoids the GoRouter recreation bug from ref.watch. +// v1.2.0: registered SessionJourneyNavigatorObserver (session/journey +// tracking, plan ancient-petting-hejlsberg.md §3/§5). import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; +import '../core/analytics/session_journey.dart'; import '../core/auth/auth_notifier.dart'; import '../core/auth/auth_state.dart'; import '../features/auth/presentation/login_screen.dart'; @@ -40,11 +43,7 @@ class _AuthRefreshNotifier extends ChangeNotifier { } /// Routes that are accessible without authentication. -const _publicRoutes = { - AppRoutes.welcome, - AppRoutes.login, - AppRoutes.jitsi, -}; +const _publicRoutes = {AppRoutes.welcome, AppRoutes.login, AppRoutes.jitsi}; @Riverpod(keepAlive: true) GoRouter router(Ref ref) { @@ -57,6 +56,7 @@ GoRouter router(Ref ref) { initialLocation: AppRoutes.root, refreshListenable: notifier, debugLogDiagnostics: false, + observers: [SessionJourneyNavigatorObserver()], redirect: (BuildContext context, GoRouterState state) { // Read (not watch) so redirect does not itself trigger provider changes. final authState = ref.read(authProvider); diff --git a/lib/core/analytics/session_journey.dart b/lib/core/analytics/session_journey.dart new file mode 100644 index 0000000..6dbe5f5 --- /dev/null +++ b/lib/core/analytics/session_journey.dart @@ -0,0 +1,668 @@ +/// 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.0 | Created: 2026-09-25 +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(); + final stepUid = newUuidV4(); + _currentScreenName = screenName; + _currentStepUid = stepUid; + _currentScreenEnteredAt = _now(); + _startHeartbeatTimer(); + _enqueue({ + 'action': 'session_enter', + 'appKey': sessionJourneyAppKey, + 'visitorId': visitorId, + 'sessionId': sessionId, + '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, + ); + } +} diff --git a/lib/core/analytics/sj_local_store.dart b/lib/core/analytics/sj_local_store.dart new file mode 100644 index 0000000..c19659d --- /dev/null +++ b/lib/core/analytics/sj_local_store.dart @@ -0,0 +1,103 @@ +// Version: 1.0.0 | Created: 2026-09-25 +// Minimal string key-value persistence for SessionJourney only. +// +// This repo has no `shared_preferences` dependency and no shared offline- +// cache module (GardenBuddy's `core/offline/offline_cache.dart` does not +// exist here — verified by reading the repo before writing this file). It +// does already depend on `package:web` for other web-only interop +// (`jitsi_web_service.dart`, `web_push_service.dart`, `panic_repository.dart`), +// and on `flutter_secure_storage` for the one Matrix device-id value it +// persists (`core/auth/secure_storage.dart`), which is async-only and +// intended for a genuine secret, not a fits-poorly match for a small +// synchronous read/write queue that gets touched on every screen change and +// every pointer-down cluster. Rather than force this module's synchronous +// read pattern (see `session_journey.dart`'s `visitorId`/`sessionId` +// getters) onto an async API, or add a new package for a handful of small +// JSON strings, this hand-rolls the same "localStorage on web, in-memory +// elsewhere" shape GardenBuddy's `core/offline/key_value_store.dart` + +// `platform_store_web.dart`/`platform_store_stub.dart` already use, scoped +// to this module only since no wider offline system exists in this app yet. +// +// M8Chat is deployed as a web build only right now (this rollout targets +// app2.m8chat.au; no new Play Store submission is in scope per +// `google-play-publishing.md` / the plan). The in-memory fallback below +// exists so `flutter analyze`/`flutter test` (VM target) still compile and +// pass; on a real non-web run it simply would not persist across restarts, +// same documented limitation GardenBuddy accepted for its own store. +library; + +import 'dart:convert'; + +import 'sj_local_store_stub.dart' + if (dart.library.js_interop) 'sj_local_store_web.dart'; + +abstract class SjKeyValueStore { + String? read(String key); + + /// Best effort: implementations swallow quota errors, a failed cache write + /// must never break the app. + void write(String key, String value); +} + +/// In-memory store for the test VM and as a universal fallback. +class SjInMemoryKeyValueStore implements SjKeyValueStore { + final Map _data = {}; + + @override + String? read(String key) => _data[key]; + + @override + void write(String key, String value) => _data[key] = value; +} + +SjKeyValueStore createSjPlatformKeyValueStore() => createSjLocalStore(); + +/// Thin JSON-envelope wrapper matching GardenBuddy's `OfflineCache.readJson`/ +/// `writeJson` shape, scoped to the handful of keys SessionJourney needs. +class SjLocalStore { + SjLocalStore({SjKeyValueStore? store, DateTime Function()? now}) + : _store = store ?? createSjPlatformKeyValueStore(), + _now = now ?? DateTime.now; + + final SjKeyValueStore _store; + final DateTime Function() _now; + + void writeJson(String key, Object? data) { + final String encoded; + try { + encoded = jsonEncode({ + 'saved_at': _now().toIso8601String(), + 'data': data, + }); + } on Object { + return; + } + _store.write(key, encoded); + } + + Object? readJson(String key) { + final raw = _store.read(key); + if (raw == null) { + return null; + } + final Object? decoded; + try { + decoded = jsonDecode(raw); + } on FormatException { + return null; + } + if (decoded is! Map) { + return null; + } + return decoded['data']; + } +} + +/// Local storage keys, kept in one place like GardenBuddy's +/// `OfflineCacheKeys` (scoped here to SessionJourney's own keys only). +abstract final class SjStoreKeys { + static const String visitorId = 'm8c.sj.visitor_id'; + static const String sessionId = 'm8c.sj.session_id'; + static const String sessionLastActive = 'm8c.sj.session_last_active'; + static const String queue = 'm8c.sj.queue'; +} diff --git a/lib/core/analytics/sj_local_store_stub.dart b/lib/core/analytics/sj_local_store_stub.dart new file mode 100644 index 0000000..17cf357 --- /dev/null +++ b/lib/core/analytics/sj_local_store_stub.dart @@ -0,0 +1,8 @@ +// Version: 1.0.0 | Created: 2026-09-25 +// Non-web platforms (the test VM, and any future native build): nothing +// persists. See sj_local_store.dart's header for why. +library; + +import 'sj_local_store.dart'; + +SjKeyValueStore createSjLocalStore() => SjInMemoryKeyValueStore(); diff --git a/lib/core/analytics/sj_local_store_web.dart b/lib/core/analytics/sj_local_store_web.dart new file mode 100644 index 0000000..3f503b2 --- /dev/null +++ b/lib/core/analytics/sj_local_store_web.dart @@ -0,0 +1,26 @@ +// Version: 1.0.0 | Created: 2026-09-25 +// Web: window.localStorage. Synchronous, ~5MB per origin, survives reloads. +// Same package (`package:web`) and pattern already used elsewhere in this +// repo (jitsi_web_service.dart, web_push_service.dart, panic_repository.dart) +// and by GardenBuddy's platform_store_web.dart. +library; + +import 'package:web/web.dart' as web; + +import 'sj_local_store.dart'; + +class _SjLocalStorageStore implements SjKeyValueStore { + @override + String? read(String key) => web.window.localStorage.getItem(key); + + @override + void write(String key, String value) { + try { + web.window.localStorage.setItem(key, value); + } catch (_) { + // QuotaExceededError or private-mode restrictions. Best effort. + } + } +} + +SjKeyValueStore createSjLocalStore() => _SjLocalStorageStore(); diff --git a/lib/main.dart b/lib/main.dart index 9aedeef..636e94c 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,5 +1,11 @@ -// Version: 1.2.0 | Created: 2026-04-01 | Updated: 2026-04-27 +// Version: 1.3.0 | Created: 2026-04-01 | Updated: 2026-09-25 // Application entry point. +// v1.3.0: wires SessionJourney (session/journey/friction tracking, ported +// from the WordPress sites per plan ancient-petting-hejlsberg.md §3/§5). +// WidgetsFlutterBinding.ensureInitialized() stays the literal first line — +// calling SessionJourney.init() before it crashes the release web build +// with a null-check error (found and fixed on GardenBuddy the same day, +// 2026-09-25 — not repeating that mistake here). import 'dart:async'; @@ -9,6 +15,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:olm/olm.dart' as olm; import 'app/app.dart'; +import 'core/analytics/session_journey.dart'; import 'core/push/web_push_service.dart'; void main() async { @@ -17,7 +24,9 @@ void main() async { // Check Olm availability — this prints to the browser console. try { await olm.init(); - debugPrint('[M8Chat] Olm initialized successfully: ${olm.get_library_version()}'); + debugPrint( + '[M8Chat] Olm initialized successfully: ${olm.get_library_version()}', + ); } catch (e) { debugPrint('[M8Chat] Olm NOT available: $e'); } @@ -41,5 +50,10 @@ void main() async { return true; // returning true marks the error as handled }; + // SessionJourney.init() runs AFTER the two handlers above so it CHAINS to + // them (captures them as "previous", calls them after its own reporting) + // rather than replacing them. See session_journey.dart's header. + SessionJourney.init(appVersion: '1.12.0'); + runApp(const ProviderScope(child: M8ChatApp())); } diff --git a/pubspec.yaml b/pubspec.yaml index d30956a..1f81f3b 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: m8chat_app description: "M8Chat — Matrix chat client for Android, iOS, and Web." publish_to: 'none' -version: 1.11.0+15 +version: 1.12.0+16 environment: sdk: '>=3.11.0 <4.0.0'