The sessionId getter mints a fresh session on cold start or after a 30-min lapse, and as a side effect nulls _currentScreenName/_currentStepUid. trackScreen() set those fields before calling the sessionId getter, so the mint-on-cold-start side effect could immediately null the fields it had just set, breaking the same-screen idempotency check and firing a duplicate session_enter right after cold start. Fix: resolve visitorId and sessionId into local variables before assigning _currentScreenName/_currentStepUid, and use the locals in the _enqueue() payload. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
686 lines
24 KiB
Dart
686 lines
24 KiB
Dart
/// Session / screen-journey / friction tracker for the Flutter estate.
|
|
///
|
|
/// Ports the WordPress sites' SessionJourney module (help4bis-siteanalytics)
|
|
/// to Flutter, per plan `ancient-petting-hejlsberg.md` §3. One file, written
|
|
/// once and copied into each in-scope app's `lib/core/analytics/` with the
|
|
/// [sessionJourneyAppKey] differing per copy (no shared pub workspace exists
|
|
/// across these repos, so a copy-with-version-header is the right-sized
|
|
/// solution). This copy is wired for M8Chat (`m8chat.au-app`), rolled out
|
|
/// after the GardenBuddy pilot (Phase 2) per the plan's §5 rollout order.
|
|
///
|
|
/// Talks to `stats.help4bis.com/track.php` v2 via its `action`-based JSON
|
|
/// actions, NOT the legacy `{s,u,w,r}` pageview beacon (M8Chat currently has
|
|
/// no such beacon in `web/index.html` at all — out of scope here, per the
|
|
/// plan this task covers SessionJourney only).
|
|
///
|
|
/// ## Where this copy differs from GardenBuddy's (read before copying again)
|
|
///
|
|
/// **Persistence.** GardenBuddy's copy depends on `core/offline/offline_cache.dart`,
|
|
/// a shared module this repo does not have (verified by reading this repo
|
|
/// before writing this file — no `shared_preferences`, no existing
|
|
/// localStorage abstraction; only `flutter_secure_storage`, async-only and
|
|
/// reserved for the one real secret this app persists, the Matrix device
|
|
/// id). This copy uses a small sibling module, `sj_local_store.dart`
|
|
/// (+ `_stub`/`_web` platform split), hand-rolled the same way GardenBuddy's
|
|
/// own store is, scoped to SessionJourney's own keys only. See that file's
|
|
/// header for the full reasoning.
|
|
///
|
|
/// **Routing / screen naming.** M8Chat uses GoRouter (`MaterialApp.router`,
|
|
/// `lib/app/router.dart`), not GardenBuddy's Navigator 1.0 + IndexedStack
|
|
/// tabs, and its routes are plain `path:`-based `GoRoute`s with no explicit
|
|
/// `name:`. Unlike GardenBuddy's copy (which had to document an
|
|
/// `"unnamed_route"` gap), M8Chat gets real per-screen names for free:
|
|
/// go_router 17.2.0's page builder (`builder.dart`) sets
|
|
/// `name: state.name ?? state.path`, so `route.settings.name` resolves to
|
|
/// the route's path (`/rooms`, `/rooms/:roomId`, `/profile`, `/spaces`,
|
|
/// `/jitsi`, `/welcome`, `/login`, `/calls/:roomId`) — verified by reading
|
|
/// the installed go_router source, not assumed. So
|
|
/// [SessionJourneyNavigatorObserver] alone (registered on `GoRouter`'s
|
|
/// `observers:`) covers every screen in this app; there is no per-tab
|
|
/// `trackScreen` call to add anywhere, unlike GardenBuddy's `HomeShell`.
|
|
///
|
|
/// **Error-handler chaining.** `lib/main.dart` already sets both
|
|
/// `FlutterError.onError` and `PlatformDispatcher.instance.onError` to
|
|
/// `debugPrint`-only handlers. [SessionJourney.init] chains to whatever is
|
|
/// already assigned at the point it runs (unconditionally, same as
|
|
/// GardenBuddy's copy) — the caller in `main()` must call [SessionJourney.init]
|
|
/// *after* those two assignments so this module captures them as
|
|
/// `previousFlutterOnError`/`previousPlatformOnError` rather than being
|
|
/// captured itself and silently dropped.
|
|
///
|
|
/// Everything else (identity, sliding session window, heartbeat cadence,
|
|
/// rage-tap clustering, queue/flush shape, server contract) is unchanged
|
|
/// from the GardenBuddy copy — see that file's header for the full design
|
|
/// rationale, including the documented `flutter_visitors_v2.session_count`
|
|
/// accounting gap in track.php v2 (re-sending `session_enter` per screen
|
|
/// change is the only way the deployed server creates a
|
|
/// `flutter_screen_views` row per screen; this is a Phase-1 backend gap,
|
|
/// not a client bug, and applies identically here).
|
|
///
|
|
/// Version: 1.0.1 | Created: 2026-09-25 | Updated: 2026-09-25
|
|
/// v1.0.1: trackScreen() resolved visitorId/sessionId into locals
|
|
/// (resolvedVisitorId/resolvedSessionId) BEFORE assigning
|
|
/// _currentScreenName/_currentStepUid, so the sessionId getter's
|
|
/// mint-on-cold-start side effect (which nulls those fields) can no
|
|
/// longer race the same-screen idempotency check and cause a duplicate
|
|
/// session_enter right after cold start.
|
|
library;
|
|
|
|
import 'dart:async';
|
|
import 'dart:convert';
|
|
import 'dart:math';
|
|
|
|
import 'package:flutter/foundation.dart';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:http/http.dart' as http;
|
|
|
|
import 'sj_local_store.dart';
|
|
|
|
/// The one line that differs (besides the header comment) between per-app
|
|
/// copies of this file. Must match an entry in track.php v2's
|
|
/// `$allowed_sites`, confirmed by reading
|
|
/// `sites/stats.help4bis.com/track.php` on brisbane01 directly before this
|
|
/// file was written: `m8chat.au-app` / origin `https://app2.m8chat.au`.
|
|
const String sessionJourneyAppKey = 'm8chat.au-app';
|
|
|
|
const String _trackOrigin = 'https://stats.help4bis.com';
|
|
const String _trackPath = '/track.php';
|
|
|
|
/// Sliding session window: a gap shorter than this while backgrounded
|
|
/// resumes the same session; longer, or no persisted timestamp at all
|
|
/// (cold start with nothing recent), starts a fresh one.
|
|
const Duration sessionTimeout = Duration(minutes: 30);
|
|
|
|
/// Flush triggers: whichever comes first.
|
|
const int flushBatchSize = 25;
|
|
const Duration flushInterval = Duration(seconds: 10);
|
|
|
|
/// How often an open screen sends a heartbeat while it stays the active one.
|
|
const Duration heartbeatInterval = Duration(seconds: 15);
|
|
|
|
/// Oldest-dropped cap on the durable outbox, generous for a normal session.
|
|
const int maxQueueLength = 300;
|
|
|
|
const Duration _requestTimeout = Duration(seconds: 6);
|
|
|
|
/// Rage-tap clustering: >= this many PointerDownEvents within [rageTapWindow]
|
|
/// and [rageTapProximityPx] of each other counts as one rage-tap cluster.
|
|
const int rageTapThreshold = 4;
|
|
const Duration rageTapWindow = Duration(milliseconds: 1500);
|
|
const double rageTapProximityPx = 40;
|
|
|
|
/// Engagement event kinds the server allowlists (see track.php's
|
|
/// `$kind_allowlist`). Kept here so a bad call fails fast, client side.
|
|
enum EngagementKind { rageTap, error, elementSeen, custom }
|
|
|
|
extension on EngagementKind {
|
|
String get wireValue => switch (this) {
|
|
EngagementKind.rageTap => 'rage_tap',
|
|
EngagementKind.error => 'error',
|
|
EngagementKind.elementSeen => 'element_seen',
|
|
EngagementKind.custom => 'custom',
|
|
};
|
|
}
|
|
|
|
/// A fresh UUIDv4 (RFC 4122 §4.4), generated locally. No `uuid` package
|
|
/// dependency, and track.php's `is_uuid_v4()` regex requires the real shape
|
|
/// (version nibble `4`, variant nibble `8-b`).
|
|
String newUuidV4({Random? random}) {
|
|
final rnd = random ?? Random.secure();
|
|
final bytes = List<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();
|
|
// Resolve visitorId/sessionId BEFORE assigning _currentScreenName:
|
|
// sessionId's getter mints (and persists) a fresh session on a lapsed
|
|
// or cold window, and as a documented side effect of that mint it
|
|
// nulls _currentScreenName/_currentStepUid so the next trackScreen()
|
|
// re-enters cleanly under the new session. Reading it after already
|
|
// setting _currentScreenName below would immediately undo that
|
|
// assignment on every cold start, breaking this method's own
|
|
// same-screen idempotency and causing a duplicate session_enter on
|
|
// the very next call for the same screen. Found and fixed 2026-09-25.
|
|
final resolvedVisitorId = visitorId;
|
|
final resolvedSessionId = sessionId;
|
|
final stepUid = newUuidV4();
|
|
_currentScreenName = screenName;
|
|
_currentStepUid = stepUid;
|
|
_currentScreenEnteredAt = _now();
|
|
_startHeartbeatTimer();
|
|
_enqueue({
|
|
'action': 'session_enter',
|
|
'appKey': sessionJourneyAppKey,
|
|
'visitorId': resolvedVisitorId,
|
|
'sessionId': resolvedSessionId,
|
|
'stepUid': stepUid,
|
|
'platform': kIsWeb ? 'web' : 'unknown',
|
|
'appVersion': appVersion,
|
|
'referrer': null,
|
|
'screen': screenName,
|
|
});
|
|
}
|
|
|
|
void _startHeartbeatTimer() {
|
|
_heartbeatTimer?.cancel();
|
|
_heartbeatTimer = Timer.periodic(_heartbeatEvery, (_) => _heartbeat());
|
|
}
|
|
|
|
/// No idle/focus detection wired in v1: `activeSeconds` is treated as
|
|
/// equal to elapsed dwell time on the screen, a deliberate simplification.
|
|
void _heartbeat() {
|
|
final stepUid = _currentStepUid;
|
|
final enteredAt = _currentScreenEnteredAt;
|
|
if (stepUid == null || enteredAt == null) {
|
|
return;
|
|
}
|
|
final elapsed = _now().difference(enteredAt).inSeconds;
|
|
_enqueue({
|
|
'action': 'heartbeat',
|
|
'appKey': sessionJourneyAppKey,
|
|
'sessionId': sessionId,
|
|
'stepUid': stepUid,
|
|
'dwellSeconds': elapsed,
|
|
'activeSeconds': elapsed,
|
|
'scrollPct': null,
|
|
});
|
|
}
|
|
|
|
void _leaveCurrentScreen() {
|
|
_heartbeatTimer?.cancel();
|
|
_heartbeatTimer = null;
|
|
final stepUid = _currentStepUid;
|
|
final enteredAt = _currentScreenEnteredAt;
|
|
if (stepUid == null || enteredAt == null) {
|
|
return;
|
|
}
|
|
final elapsed = _now().difference(enteredAt).inSeconds;
|
|
_enqueue({
|
|
'action': 'screen_leave',
|
|
'appKey': sessionJourneyAppKey,
|
|
'sessionId': sessionId,
|
|
'stepUid': stepUid,
|
|
'dwellSeconds': elapsed,
|
|
'activeSeconds': elapsed,
|
|
'scrollPct': null,
|
|
});
|
|
_currentStepUid = null;
|
|
_currentScreenEnteredAt = null;
|
|
}
|
|
|
|
// --- Engagement ------------------------------------------------------------
|
|
|
|
/// Queues one `engagement` event. Each call is its own request (`events`
|
|
/// as a single-item array) rather than coalescing several into one POST.
|
|
void _recordEngagement(
|
|
EngagementKind kind, {
|
|
String? label,
|
|
String? context,
|
|
String? value,
|
|
}) {
|
|
_enqueue({
|
|
'action': 'engagement',
|
|
'appKey': sessionJourneyAppKey,
|
|
'sessionId': sessionId,
|
|
'visitorId': visitorId,
|
|
'events': [
|
|
{
|
|
'stepUid': _currentStepUid,
|
|
'kind': kind.wireValue,
|
|
'label': label,
|
|
'context': context,
|
|
'value': value,
|
|
'occurredAt': _now().toIso8601String(),
|
|
},
|
|
],
|
|
});
|
|
}
|
|
|
|
/// One-off business events an app instruments itself
|
|
/// (`SessionJourney.instance.trackCustomEvent(...)`).
|
|
void trackCustomEvent(String label, String context, {num? value}) {
|
|
if (label.isEmpty) {
|
|
return;
|
|
}
|
|
_recordEngagement(
|
|
EngagementKind.custom,
|
|
label: label,
|
|
context: context,
|
|
value: value?.toString(),
|
|
);
|
|
}
|
|
|
|
/// Reported by [FlutterError.onError] / `PlatformDispatcher`'s handler
|
|
/// (wired in [init], chained after `main.dart`'s existing debugPrint
|
|
/// handlers), and available for a manual `try`/`catch` to call directly.
|
|
/// Truncated: never send a huge stack dump as `context`.
|
|
void recordError(String message) {
|
|
const maxLength = 300;
|
|
final truncated = message.length > maxLength
|
|
? '${message.substring(0, maxLength)}…'
|
|
: message;
|
|
_recordEngagement(
|
|
EngagementKind.error,
|
|
label: _currentScreenName ?? 'unknown_screen',
|
|
context: truncated,
|
|
);
|
|
}
|
|
|
|
// --- Rage-tap detection ------------------------------------------------
|
|
|
|
final List<_PendingTap> _recentTaps = [];
|
|
|
|
/// Called from [SessionJourneyGestureLayer]'s root `Listener` for every
|
|
/// `PointerDownEvent`. Clusters by time ([rageTapWindow]) and proximity
|
|
/// ([rageTapProximityPx]); firing a `rage_tap` engagement event resets the
|
|
/// cluster so the same burst cannot fire twice.
|
|
void recordTap(Offset position) {
|
|
final now = _now();
|
|
_recentTaps.removeWhere((t) => now.difference(t.at) > rageTapWindow);
|
|
_recentTaps.add(_PendingTap(now, position));
|
|
final cluster = _recentTaps
|
|
.where((t) => (t.position - position).distance <= rageTapProximityPx)
|
|
.toList();
|
|
if (cluster.length >= rageTapThreshold) {
|
|
final screen = _currentScreenName ?? 'unknown_screen';
|
|
_recordEngagement(
|
|
EngagementKind.rageTap,
|
|
label: screen,
|
|
context:
|
|
'${cluster.length} taps near '
|
|
'(${position.dx.round()},${position.dy.round()}) on $screen',
|
|
);
|
|
_recentTaps.clear();
|
|
}
|
|
}
|
|
|
|
// --- Queue / flush -----------------------------------------------------
|
|
|
|
Uri get _trackUri => Uri.parse('$_trackOrigin$_trackPath');
|
|
|
|
List<_QueuedRequest> _readQueue() {
|
|
final data = _cache.readJson(SjStoreKeys.queue);
|
|
if (data is! List) {
|
|
return [];
|
|
}
|
|
return data
|
|
.map(_QueuedRequest.fromJson)
|
|
.whereType<_QueuedRequest>()
|
|
.toList();
|
|
}
|
|
|
|
void _writeQueue(List<_QueuedRequest> queue) {
|
|
_cache.writeJson(SjStoreKeys.queue, queue.map((q) => q.toJson()).toList());
|
|
}
|
|
|
|
/// The queue as it stands right now. Visible for tests.
|
|
List<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,
|
|
);
|
|
}
|
|
}
|