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