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:
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user