feat(analytics): port SessionJourney tracking from GardenBuddy (v1.12.0+16)

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.
This commit is contained in:
2026-09-25 14:34:18 +10:00
parent e4aa5277d7
commit 729822c97e
8 changed files with 846 additions and 20 deletions
+9 -2
View File
@@ -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 // Root MaterialApp widget. Wires together theme + router + incoming-call
// overlay (v1.1.0 — the overlay existed but was never mounted, so incoming // overlay (v1.1.0 — the overlay existed but was never mounted, so incoming
// calls showed nothing). // 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/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../core/analytics/session_journey.dart';
import '../features/calls/presentation/incoming_call_overlay.dart'; import '../features/calls/presentation/incoming_call_overlay.dart';
import 'router.dart'; import 'router.dart';
import 'theme.dart'; import 'theme.dart';
@@ -21,7 +26,8 @@ class M8ChatApp extends ConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final router = ref.watch(routerProvider); final router = ref.watch(routerProvider);
return MaterialApp.router( return SessionJourneyGestureLayer(
child: MaterialApp.router(
title: 'M8Chat', title: 'M8Chat',
theme: buildLightTheme(), theme: buildLightTheme(),
darkTheme: buildDarkTheme(), darkTheme: buildDarkTheme(),
@@ -31,6 +37,7 @@ class M8ChatApp extends ConsumerWidget {
debugShowCheckedModeBanner: false, debugShowCheckedModeBanner: false,
builder: (context, child) => builder: (context, child) =>
IncomingCallOverlayHost(child: child ?? const SizedBox.shrink()), IncomingCallOverlayHost(child: child ?? const SizedBox.shrink()),
),
); );
} }
} }
+6 -6
View File
@@ -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. // All route definitions in one place.
// GoRouter is created ONCE (keepAlive) and re-evaluates redirects via // GoRouter is created ONCE (keepAlive) and re-evaluates redirects via
// refreshListenable — avoids the GoRouter recreation bug from ref.watch. // 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:flutter/material.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart';
import '../core/analytics/session_journey.dart';
import '../core/auth/auth_notifier.dart'; import '../core/auth/auth_notifier.dart';
import '../core/auth/auth_state.dart'; import '../core/auth/auth_state.dart';
import '../features/auth/presentation/login_screen.dart'; import '../features/auth/presentation/login_screen.dart';
@@ -40,11 +43,7 @@ class _AuthRefreshNotifier extends ChangeNotifier {
} }
/// Routes that are accessible without authentication. /// Routes that are accessible without authentication.
const _publicRoutes = { const _publicRoutes = {AppRoutes.welcome, AppRoutes.login, AppRoutes.jitsi};
AppRoutes.welcome,
AppRoutes.login,
AppRoutes.jitsi,
};
@Riverpod(keepAlive: true) @Riverpod(keepAlive: true)
GoRouter router(Ref ref) { GoRouter router(Ref ref) {
@@ -57,6 +56,7 @@ GoRouter router(Ref ref) {
initialLocation: AppRoutes.root, initialLocation: AppRoutes.root,
refreshListenable: notifier, refreshListenable: notifier,
debugLogDiagnostics: false, debugLogDiagnostics: false,
observers: [SessionJourneyNavigatorObserver()],
redirect: (BuildContext context, GoRouterState state) { redirect: (BuildContext context, GoRouterState state) {
// Read (not watch) so redirect does not itself trigger provider changes. // Read (not watch) so redirect does not itself trigger provider changes.
final authState = ref.read(authProvider); final authState = ref.read(authProvider);
+668
View File
@@ -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<int>.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<String, Object?> body;
final DateTime queuedAt;
Map<String, Object?> 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<String, Object?>.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<void> _inFlightFlush = Future<void>.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<Map<String, Object?>> get queue =>
_readQueue().map((q) => q.body).toList();
void _enqueue(Map<String, Object?> 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<void> flush() {
if (_flushPending == false && _readQueue().isEmpty) {
return Future<void>.value();
}
_flushPending = false;
final chained = _inFlightFlush.then((_) => _runFlush());
_inFlightFlush = chained;
return chained;
}
Future<void> _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<bool> _post(Map<String, Object?> 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<Object?> route) =>
route.settings.name ?? 'unnamed_route';
@override
void didPush(Route<Object?> route, Route<Object?>? previousRoute) {
if (route is PageRoute) {
_instance?.trackScreen(_nameOf(route));
}
}
@override
void didPop(Route<Object?> route, Route<Object?>? previousRoute) {
if (previousRoute is PageRoute) {
_instance?.trackScreen(_nameOf(previousRoute));
}
}
@override
void didReplace({Route<Object?>? newRoute, Route<Object?>? 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,
);
}
}
+103
View File
@@ -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<String, String> _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<String, Object?>) {
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';
}
@@ -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();
@@ -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();
+16 -2
View File
@@ -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. // 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'; import 'dart:async';
@@ -9,6 +15,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:olm/olm.dart' as olm; import 'package:olm/olm.dart' as olm;
import 'app/app.dart'; import 'app/app.dart';
import 'core/analytics/session_journey.dart';
import 'core/push/web_push_service.dart'; import 'core/push/web_push_service.dart';
void main() async { void main() async {
@@ -17,7 +24,9 @@ void main() async {
// Check Olm availability — this prints to the browser console. // Check Olm availability — this prints to the browser console.
try { try {
await olm.init(); await olm.init();
debugPrint('[M8Chat] Olm initialized successfully: ${olm.get_library_version()}'); debugPrint(
'[M8Chat] Olm initialized successfully: ${olm.get_library_version()}',
);
} catch (e) { } catch (e) {
debugPrint('[M8Chat] Olm NOT available: $e'); debugPrint('[M8Chat] Olm NOT available: $e');
} }
@@ -41,5 +50,10 @@ void main() async {
return true; // returning true marks the error as handled 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())); runApp(const ProviderScope(child: M8ChatApp()));
} }
+1 -1
View File
@@ -1,7 +1,7 @@
name: m8chat_app name: m8chat_app
description: "M8Chat — Matrix chat client for Android, iOS, and Web." description: "M8Chat — Matrix chat client for Android, iOS, and Web."
publish_to: 'none' publish_to: 'none'
version: 1.11.0+15 version: 1.12.0+16
environment: environment:
sdk: '>=3.11.0 <4.0.0' sdk: '>=3.11.0 <4.0.0'