Fix onboarding UX and add replaceable journey analytics

Make recipient changes explicit, prioritize verification retry, and label simulated outcomes prominently. Add shared typed analytics with bounded dummy capture, correlated service events and documented PostHog adapter contract. Validate privacy, retries, abandonment, keyboard recovery and accessibility. Related to #12.
This commit is contained in:
Stephan D committed 2026-09-12 08:36:45 +02:00
1 parent bf7778f312
commit f1cbf64282
28 files changed
+1158 -64

No files matched your search

@@ -0,0 +1,3 @@
.dart_tool/
build/
pubspec.lock
@@ -0,0 +1,21 @@
# SXCHNGE analytics
Shared pure-Dart analytics contract, semantic journey recorder and bounded
in-memory dummy service. No PostHog SDK, network, persistence or logging.
```dart
final analytics = DummyAnalyticsService();
final recorder = JourneyRecorder(analytics);
final operation = recorder.begin(JourneyKind.login, JourneyStep.login);
recorder.finish(operation, simulated: true, terminal: true);
final captured = analytics.events; // immutable snapshot
recorder.dispose();
```
Inject an `AnalyticsService` implementation to replace the sink. Consumers do not
await analytics. See [the auth journey contract](../../../docs/ux/auth-journeys.md)
for schema, metric definitions, simulation rules, privacy, retry/abandonment
semantics, PostHog adapter guidance and measurement limitations.
Run `flutter test` and `flutter analyze` here. The production library imports
only Dart SDK libraries and can be reused by web and mobile.
@@ -0,0 +1,245 @@
import 'dart:async';
import 'dart:collection';
import 'dart:math';
enum JourneyKind { login, signup, verification }
enum JourneyStep { login, signup, emailDelivery, emailConfirmation }
enum JourneySignal {
started,
requested,
succeeded,
simulated,
failed,
abandoned,
}
enum JourneyFailure {
invalidCredentials,
emailInUse,
unavailable,
unexpected,
expired,
invalid,
internal,
}
enum JourneyExit { navigation, modeChange, inactivity, disposed }
String _snake(String value) =>
value.replaceAllMapped(RegExp('[A-Z]'), (m) => '_${m[0]!.toLowerCase()}');
String _id() {
final random = Random.secure();
return List.generate(
16,
(_) => random.nextInt(256).toRadixString(16).padLeft(2, '0'),
).join();
}
/// Only typed, allowlisted fields; never accepts form data, URLs or exceptions.
class AnalyticsEvent {
AnalyticsEvent({
required this.journeyId,
required this.kind,
required this.signal,
required this.sequence,
required this.elapsedMs,
this.step,
this.operation,
this.failure,
this.exit,
this.terminal = false,
this.simulated = false,
}) : eventId = _id(),
occurredAt = DateTime.now().toUtc();
final String eventId;
final String journeyId;
final JourneyKind kind;
final JourneySignal signal;
final JourneyStep? step;
final JourneyFailure? failure;
final JourneyExit? exit;
final int sequence;
final int? operation;
final int elapsedMs;
final bool terminal;
final bool simulated;
final DateTime occurredAt;
String get name =>
'auth_${step == null ? 'journey' : _snake(step!.name)}_${signal.name}';
Map<String, Object> get properties => Map.unmodifiable({
'schema_version': 1,
'event_id': eventId,
'journey_id': journeyId,
'journey_kind': kind.name,
'sequence': sequence,
'elapsed_ms': elapsedMs,
'occurred_at': occurredAt.toIso8601String(),
'terminal': terminal,
'simulated': simulated,
if (step != null) 'step': _snake(step!.name),
if (operation != null) 'operation': operation!,
if (failure != null) 'failure': _snake(failure!.name),
if (exit != null) 'exit': _snake(exit!.name),
});
}
abstract interface class AnalyticsService {
Future<void> capture(AnalyticsEvent event);
}
/// Bounded, process-local inspection only. No disk, logs, network or SDK.
class DummyAnalyticsService implements AnalyticsService {
DummyAnalyticsService({this.capacity = 500}) : assert(capacity > 0);
final int capacity;
final Queue<AnalyticsEvent> _events = Queue();
List<AnalyticsEvent> get events => List.unmodifiable(_events);
void clear() => _events.clear();
@override
Future<void> capture(AnalyticsEvent event) async {
if (_events.any((item) => item.eventId == event.eventId)) return;
if (_events.length == capacity) _events.removeFirst();
_events.add(event);
}
}
class _Journey {
_Journey(this.kind);
final JourneyKind kind;
final String id = _id();
final Stopwatch time = Stopwatch()..start();
int sequence = 0;
int operations = 0;
int pending = 0;
bool closed = false;
bool simulated = false;
}
class JourneyOperation {
JourneyOperation._(this._journey, this.step, this.number);
final _Journey _journey;
final JourneyStep step;
final int number;
bool _finished = false;
}
/// One foreground auth journey. Retried operations share a journey, not outcomes.
class JourneyRecorder {
JourneyRecorder(
this.service, {
this.idleWindow = const Duration(minutes: 30),
});
final AnalyticsService service;
final Duration idleWindow;
_Journey? _active;
Timer? _idle;
bool _disposed = false;
JourneyOperation begin(
JourneyKind kind,
JourneyStep step, {
bool onboarding = false,
bool simulated = false,
}) {
var journey = _active;
if (journey == null ||
journey.closed ||
(journey.kind != kind &&
!(onboarding && journey.kind == JourneyKind.signup))) {
abandon(JourneyExit.navigation);
journey = _Journey(kind)..simulated = simulated;
_active = journey;
_emit(journey, JourneySignal.started);
}
journey.simulated |= simulated;
_idle?.cancel();
journey.pending++;
final operation = JourneyOperation._(journey, step, ++journey.operations);
_emit(journey, JourneySignal.requested, operation: operation);
return operation;
}
void finish(
JourneyOperation operation, {
bool simulated = false,
JourneyFailure? failure,
bool terminal = false,
}) {
final journey = operation._journey;
if (_disposed || operation._finished || journey.closed) return;
operation._finished = true;
journey.pending--;
journey.simulated |= simulated;
final complete = failure == null && terminal;
_emit(
journey,
failure != null
? JourneySignal.failed
: (simulated || (complete && journey.simulated))
? JourneySignal.simulated
: JourneySignal.succeeded,
operation: operation,
failure: failure,
terminal: complete,
simulated: simulated || (complete && journey.simulated),
);
if (complete) {
journey.closed = true;
_idle?.cancel();
} else if (journey.pending == 0) {
_idle?.cancel();
_idle = Timer(idleWindow, () => abandon(JourneyExit.inactivity));
}
}
void abandon(JourneyExit reason) {
_idle?.cancel();
final journey = _active;
if (_disposed || journey == null || journey.closed) return;
_emit(
journey,
JourneySignal.abandoned,
terminal: true,
exit: reason,
simulated: journey.simulated,
);
journey.closed = true;
}
void _emit(
_Journey journey,
JourneySignal signal, {
JourneyOperation? operation,
JourneyFailure? failure,
JourneyExit? exit,
bool terminal = false,
bool simulated = false,
}) {
if (_disposed) return;
final event = AnalyticsEvent(
journeyId: journey.id,
kind: journey.kind,
signal: signal,
sequence: ++journey.sequence,
elapsedMs: journey.time.elapsedMilliseconds,
step: operation?.step,
operation: operation?.number,
failure: failure,
exit: exit,
terminal: terminal,
simulated: simulated || journey.simulated,
);
// Adapters may throw synchronously, fail asynchronously or never finish.
// None may delay or change the authentication operation.
unawaited(
Future<void>.sync(() => service.capture(event)).catchError((Object _) {}),
);
}
void dispose() {
abandon(JourneyExit.disposed);
_disposed = true;
_idle?.cancel();
}
}
@@ -0,0 +1,9 @@
name: sxchnge_analytics
description: Privacy-minimal journey analytics contracts and in-memory dummy service.
publish_to: none
version: 0.1.0
environment:
sdk: '>=3.13.0 <4.0.0'
dev_dependencies:
flutter_test:
sdk: flutter
@@ -0,0 +1,116 @@
import 'dart:async';
import 'package:flutter_test/flutter_test.dart';
import 'package:sxchnge_analytics/sxchnge_analytics.dart';
class BrokenService implements AnalyticsService {
BrokenService(this.mode);
final int mode;
@override
Future<void> capture(AnalyticsEvent event) {
if (mode == 0) throw StateError('offline');
if (mode == 1) return Future.error(StateError('offline'));
return Completer<void>().future;
}
}
void main() {
test('signup through verification shares ID; retries and duplicates do not inflate outcomes', () {
final sink = DummyAnalyticsService();
final recorder = JourneyRecorder(sink);
addTearDown(recorder.dispose);
final signup = recorder.begin(JourneyKind.signup, JourneyStep.signup);
recorder.finish(signup, simulated: true);
final send = recorder.begin(
JourneyKind.verification,
JourneyStep.emailDelivery,
onboarding: true,
);
recorder.finish(send, simulated: true);
final failed = recorder.begin(
JourneyKind.verification,
JourneyStep.emailConfirmation,
onboarding: true,
);
recorder.finish(failed, failure: JourneyFailure.expired);
final retry = recorder.begin(
JourneyKind.verification,
JourneyStep.emailConfirmation,
onboarding: true,
);
recorder.finish(retry, simulated: true, terminal: true);
recorder.finish(retry, simulated: true, terminal: true);
expect(sink.events.map((e) => e.journeyId).toSet().length, 1);
expect(sink.events.where((e) => e.terminal).length, 1);
expect(sink.events.last.signal, JourneySignal.simulated);
expect(sink.events.last.operation, 4);
expect(
sink.events.map((e) => e.sequence),
orderedEquals(List.generate(9, (i) => i + 1)),
);
expect(
sink.events.any((e) => e.properties.toString().contains('email_address')),
isFalse,
);
});
test(
'explicit exit records once and suppresses late operation completion',
() {
final sink = DummyAnalyticsService();
final recorder = JourneyRecorder(sink);
addTearDown(recorder.dispose);
final op = recorder.begin(JourneyKind.login, JourneyStep.login);
recorder.abandon(JourneyExit.modeChange);
recorder.abandon(JourneyExit.navigation);
recorder.finish(op, terminal: true);
expect(sink.events.last.signal, JourneySignal.abandoned);
expect(sink.events.where((e) => e.terminal).length, 1);
final newOp = recorder.begin(JourneyKind.login, JourneyStep.login);
recorder.finish(newOp, terminal: true);
expect(sink.events.map((e) => e.journeyId).toSet().length, 2);
},
);
test(
'inactivity waits for pending operations and starts after settled failure',
() async {
final sink = DummyAnalyticsService();
final recorder = JourneyRecorder(
sink,
idleWindow: const Duration(milliseconds: 10),
);
addTearDown(recorder.dispose);
final op = recorder.begin(JourneyKind.login, JourneyStep.login);
await Future<void>.delayed(const Duration(milliseconds: 25));
expect(sink.events.any((e) => e.terminal), isFalse);
recorder.finish(op, failure: JourneyFailure.unavailable);
await Future<void>.delayed(const Duration(milliseconds: 25));
expect(sink.events.last.exit, JourneyExit.inactivity);
},
);
for (final mode in [0, 1, 2]) {
test('analytics failure mode $mode never blocks outcomes', () async {
final recorder = JourneyRecorder(BrokenService(mode));
final op = recorder.begin(JourneyKind.login, JourneyStep.login);
recorder.finish(op, terminal: true);
recorder.dispose();
await Future<void>.delayed(Duration.zero);
});
}
test(
'dummy is bounded, immutable and deduplicates repeated event IDs',
() async {
final sink = DummyAnalyticsService(capacity: 2);
final recorder = JourneyRecorder(sink);
addTearDown(recorder.dispose);
final op = recorder.begin(JourneyKind.login, JourneyStep.login);
final event = sink.events.last;
await sink.capture(event);
expect(sink.events.length, 2);
recorder.finish(op, terminal: true);
expect(sink.events.length, 2);
expect(() => sink.events.clear(), throwsUnsupportedError);
expect(() => event.properties['password'] = 'no', throwsUnsupportedError);
},
);
}