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

+9
View File
@@ -62,3 +62,12 @@ Use `context.goNamed(AppRoutes.… )` for top-level navigation. Existing hash UR
The signup email travels as a transient route extra. The router's extra codec deliberately omits extras from serialized browser history, so a restored sent page asks for the email again. Do not add persistent route extras without explicitly updating that policy.
These are public routes: no session or KYC guard is added to the dummy authentication flow. Future protected pages should use centralized redirects driven by authoritative session state, with backend access enforcement. Tracker: tech/sxchnge#6, branch `routing-6`.
## UX and analytics
The app accepts an optional `analyticsService` and defaults to the shared bounded
in-memory dummy analytics sink. Semantic service events correlate signup, delivery
and confirmation within this app instance. No analytics data leaves the device.
See [the journey contract](../../docs/ux/auth-journeys.md) for the schema, metrics,
PostHog adapter boundary and measurement limitations. Demo outcomes are explicitly
simulated; recipient editing and internal-error recovery have dedicated controls.
+8 -8
View File
@@ -4,6 +4,8 @@ import 'package:go_router/go_router.dart';
import 'package:provider/provider.dart';
import 'package:sxchnge_analytics/sxchnge_analytics.dart';
import 'package:sxchnge_auth/sxchnge_auth.dart';
import '../routing/routes.dart';
@@ -58,6 +60,7 @@ class _AuthFormState extends State<AuthForm> {
void _switch(bool signUp) {
if (signUp == _signUp) return;
context.read<JourneyRecorder>().abandon(JourneyExit.modeChange);
context.read<LoginProvider>().reset();
context.read<SignupProvider>().reset();
_form.currentState?.reset();
@@ -130,6 +133,11 @@ class _AuthFormState extends State<AuthForm> {
),
),
),
const SizedBox(height: 16),
Text(
l.earlyPreview,
style: text.bodySmall!.copyWith(color: colors.onSurfaceVariant),
),
const SizedBox(height: 24),
Text(
_signUp ? l.signupHeading : l.loginHeading,
@@ -316,14 +324,6 @@ class _AuthFormState extends State<AuthForm> {
),
],
),
const SizedBox(height: 24),
const Divider(),
const SizedBox(height: 16),
Text(
l.earlyPreview,
textAlign: TextAlign.center,
style: text.bodySmall!.copyWith(color: colors.onSurfaceVariant),
),
],
);
}
@@ -461,7 +461,7 @@ abstract class AppLocalizations {
/// No description provided for @verificationRetry.
///
/// In en, this message translates to:
/// **'Try again'**
/// **'Retry verification'**
String get verificationRetry;
/// No description provided for @verificationBack.
@@ -493,6 +493,48 @@ abstract class AppLocalizations {
/// In en, this message translates to:
/// **'CONFIRM YOUR EMAIL'**
String get verificationStep;
/// No description provided for @demoSentHeading.
///
/// In en, this message translates to:
/// **'Demo email prepared.'**
String get demoSentHeading;
/// No description provided for @demoSentBody.
///
/// In en, this message translates to:
/// **'No email was sent. Use the demo confirmation below to preview the next step.'**
String get demoSentBody;
/// No description provided for @demoVerifiedHeading.
///
/// In en, this message translates to:
/// **'Demo verification\ncomplete.'**
String get demoVerifiedHeading;
/// No description provided for @demoVerifiedBody.
///
/// In en, this message translates to:
/// **'You’ve previewed email confirmation. No account was verified. Return to login to explore the demo.'**
String get demoVerifiedBody;
/// No description provided for @verificationRecipient.
///
/// In en, this message translates to:
/// **'Recipient'**
String get verificationRecipient;
/// No description provided for @verificationChangeEmail.
///
/// In en, this message translates to:
/// **'Change email'**
String get verificationChangeEmail;
/// No description provided for @demoResend.
///
/// In en, this message translates to:
/// **'Prepare demo email again'**
String get demoResend;
}
class _AppLocalizationsDelegate
@@ -201,7 +201,7 @@ class AppLocalizationsEn extends AppLocalizations {
String get verificationRequest => 'Send a new verification email';
@override
String get verificationRetry => 'Try again';
String get verificationRetry => 'Retry verification';
@override
String get verificationBack => 'Back to login';
@@ -219,4 +219,27 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get verificationStep => 'CONFIRM YOUR EMAIL';
@override
String get demoSentHeading => 'Demo email prepared.';
@override
String get demoSentBody =>
'No email was sent. Use the demo confirmation below to preview the next step.';
@override
String get demoVerifiedHeading => 'Demo verification\ncomplete.';
@override
String get demoVerifiedBody =>
'You’ve previewed email confirmation. No account was verified. Return to login to explore the demo.';
@override
String get verificationRecipient => 'Recipient';
@override
String get verificationChangeEmail => 'Change email';
@override
String get demoResend => 'Prepare demo email again';
}
@@ -202,7 +202,7 @@ class AppLocalizationsRu extends AppLocalizations {
String get verificationRequest => 'Отправить новое письмо';
@override
String get verificationRetry => 'Попробовать ещё раз';
String get verificationRetry => 'Повторить подтверждение';
@override
String get verificationBack => 'Вернуться ко входу';
@@ -220,4 +220,27 @@ class AppLocalizationsRu extends AppLocalizations {
@override
String get verificationStep => 'ПОДТВЕРДИТЕ ПОЧТУ';
@override
String get demoSentHeading => 'Демописьмо готово.';
@override
String get demoSentBody =>
'Письмо не отправлено. Используйте демоссылку ниже, чтобы посмотреть следующий шаг.';
@override
String get demoVerifiedHeading => 'Демоподтверждение\nзавершено.';
@override
String get demoVerifiedBody =>
'Вы посмотрели подтверждение почты. Реальный аккаунт не подтверждён. Вернитесь ко входу, чтобы продолжить знакомство с демоверсией.';
@override
String get verificationRecipient => 'Получатель';
@override
String get verificationChangeEmail => 'Изменить почту';
@override
String get demoResend => 'Подготовить демописьмо ещё раз';
}
+9 -2
View File
@@ -60,10 +60,17 @@
"verificationInternalBody": "We couldn’t complete your request because of an internal error. Please try again.",
"verificationResend": "Resend verification email",
"verificationRequest": "Send a new verification email",
"verificationRetry": "Try again",
"verificationRetry": "Retry verification",
"verificationBack": "Back to login",
"verificationHint": "No email? Check your spam folder or request another below.",
"verificationDemo": "DEMO · No real email is sent and no account is verified.",
"verificationDemoLink": "Open demo confirmation link",
"verificationStep": "CONFIRM YOUR EMAIL"
"verificationStep": "CONFIRM YOUR EMAIL",
"demoSentHeading": "Demo email prepared.",
"demoSentBody": "No email was sent. Use the demo confirmation below to preview the next step.",
"demoVerifiedHeading": "Demo verification\ncomplete.",
"demoVerifiedBody": "You’ve previewed email confirmation. No account was verified. Return to login to explore the demo.",
"verificationRecipient": "Recipient",
"verificationChangeEmail": "Change email",
"demoResend": "Prepare demo email again"
}
+9 -2
View File
@@ -60,10 +60,17 @@
"verificationInternalBody": "Не удалось выполнить запрос из-за внутренней ошибки. Попробуйте ещё раз.",
"verificationResend": "Отправить письмо ещё раз",
"verificationRequest": "Отправить новое письмо",
"verificationRetry": "Попробовать ещё раз",
"verificationRetry": "Повторить подтверждение",
"verificationBack": "Вернуться ко входу",
"verificationHint": "Нет письма? Проверьте папку «Спам» или запросите новое ниже.",
"verificationDemo": "ДЕМО · Письмо не отправляется, реальный аккаунт не подтверждается.",
"verificationDemoLink": "Открыть демоссылку подтверждения",
"verificationStep": "ПОДТВЕРДИТЕ ПОЧТУ"
"verificationStep": "ПОДТВЕРДИТЕ ПОЧТУ",
"demoSentHeading": "Демописьмо готово.",
"demoSentBody": "Письмо не отправлено. Используйте демоссылку ниже, чтобы посмотреть следующий шаг.",
"demoVerifiedHeading": "Демоподтверждение\nзавершено.",
"demoVerifiedBody": "Вы посмотрели подтверждение почты. Реальный аккаунт не подтверждён. Вернитесь ко входу, чтобы продолжить знакомство с демоверсией.",
"verificationRecipient": "Получатель",
"verificationChangeEmail": "Изменить почту",
"demoResend": "Подготовить демописьмо ещё раз"
}
+36 -6
View File
@@ -2,6 +2,8 @@ import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:sxchnge_analytics/sxchnge_analytics.dart';
import 'package:sxchnge_auth/sxchnge_auth.dart';
import 'package:sxchnge_theme/sxchnge_theme.dart';
@@ -21,11 +23,13 @@ class SxchngeApp extends StatefulWidget {
const SxchngeApp({
super.key,
this.localeProvider,
this.analyticsService,
this.loginService = const DummyLoginService(),
this.signupService = const DummySignupService(),
this.verificationService = const DummyVerificationService(),
});
final LocaleProvider? localeProvider;
final AnalyticsService? analyticsService;
final LoginService loginService;
final SignupService signupService;
final VerificationService verificationService;
@@ -37,11 +41,38 @@ class SxchngeApp extends StatefulWidget {
class _SxchngeAppState extends State<SxchngeApp> {
late final _locale = widget.localeProvider ?? LocaleProvider();
late final _router = createAppRouter();
late final _journeys = JourneyRecorder(
widget.analyticsService ?? DummyAnalyticsService(),
);
late final _services = InstrumentedAuthServices(
recorder: _journeys,
loginService: widget.loginService,
signupService: widget.signupService,
verificationService: widget.verificationService,
);
late final _router = createAppRouter()
..routeInformationProvider.addListener(_routeChanged);
String _previousPath = '/';
@override
void initState() {
super.initState();
_previousPath = _router.routeInformationProvider.value.uri.path;
}
void _routeChanged() {
final path = _router.routeInformationProvider.value.uri.path;
if (path == '/' && _previousPath != '/') {
_journeys.abandon(JourneyExit.navigation);
}
_previousPath = path;
}
@override
void dispose() {
_router.routeInformationProvider.removeListener(_routeChanged);
_router.dispose();
_journeys.dispose();
_locale.dispose();
super.dispose();
}
@@ -49,12 +80,11 @@ class _SxchngeAppState extends State<SxchngeApp> {
@override
Widget build(BuildContext context) => MultiProvider(
providers: [
Provider<JourneyRecorder>.value(value: _journeys),
ChangeNotifierProvider.value(value: _locale),
Provider<VerificationService>.value(value: widget.verificationService),
ChangeNotifierProvider(create: (_) => LoginProvider(widget.loginService)),
ChangeNotifierProvider(
create: (_) => SignupProvider(widget.signupService),
),
Provider<VerificationService>.value(value: _services),
ChangeNotifierProvider(create: (_) => LoginProvider(_services)),
ChangeNotifierProvider(create: (_) => SignupProvider(_services)),
],
child: Consumer<LocaleProvider>(
builder: (context, locale, _) => MaterialApp.router(
+99 -36
View File
@@ -4,6 +4,8 @@ import 'package:go_router/go_router.dart';
import 'package:provider/provider.dart';
import 'package:sxchnge_analytics/sxchnge_analytics.dart';
import 'package:sxchnge_auth/sxchnge_auth.dart';
import '../auth/brand_panel.dart';
@@ -28,6 +30,8 @@ class _VerificationPageState extends State<VerificationPage> {
late final _provider = VerificationProvider(
context.read<VerificationService>(),
);
final _emailFocus = FocusNode();
String? _recipient;
bool _initialized = false;
bool _lastWasSend = false;
bool _validated = false;
@@ -56,6 +60,7 @@ class _VerificationPageState extends State<VerificationPage> {
void didUpdateWidget(covariant VerificationPage oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.token == widget.token) return;
context.read<JourneyRecorder>().abandon(JourneyExit.navigation);
_provider.reset();
_lastWasSend = false;
_validated = false;
@@ -68,7 +73,12 @@ class _VerificationPageState extends State<VerificationPage> {
void _send() {
if (_provider.state.isLoading) return;
_validated = true;
if (!(_form.currentState?.validate() ?? false)) return;
if (_provider.state.status != VerificationStatus.sent &&
!(_form.currentState?.validate() ?? false)) {
return;
}
_recipient = _email.text.trim();
FocusScope.of(context).unfocus();
_lastWasSend = true;
_provider.send(
email: _email.text,
@@ -76,10 +86,20 @@ class _VerificationPageState extends State<VerificationPage> {
);
}
void _changeEmail() {
_lastWasSend = true;
_validated = false;
_provider.reset();
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) _emailFocus.requestFocus();
});
}
@override
void dispose() {
_provider.dispose();
_email.dispose();
_emailFocus.dispose();
super.dispose();
}
@@ -99,21 +119,32 @@ class _VerificationPageState extends State<VerificationPage> {
].contains(status);
final verified = status == VerificationStatus.verified;
final demo =
_provider.service is DummyVerificationService || state.isSimulated;
(_provider.service is SimulationInfo &&
(_provider.service as SimulationInfo).isSimulated) ||
state.isSimulated;
final sent = status == VerificationStatus.sent;
final retryConfirmation =
status == VerificationStatus.error &&
!_lastWasSend &&
widget.token != null;
final title = switch (status) {
VerificationStatus.sent => l.checkInbox,
VerificationStatus.sent => demo ? l.demoSentHeading : l.checkInbox,
VerificationStatus.sending => l.verificationSending,
VerificationStatus.checking => l.verificationChecking,
VerificationStatus.verified => l.verificationVerified,
VerificationStatus.verified =>
demo ? l.demoVerifiedHeading : l.verificationVerified,
VerificationStatus.expired => l.verificationExpired,
VerificationStatus.invalid => l.verificationInvalid,
VerificationStatus.error => l.verificationInternal,
VerificationStatus.idle =>
widget.token != null ? l.verificationChecking : l.verificationRequest,
widget.token != null && !_lastWasSend
? l.verificationChecking
: l.verificationRequest,
};
final body = switch (status) {
VerificationStatus.sent => l.verificationSent,
VerificationStatus.verified => l.verificationVerifiedBody,
VerificationStatus.sent => demo ? l.demoSentBody : l.verificationSent,
VerificationStatus.verified =>
demo ? l.demoVerifiedBody : l.verificationVerifiedBody,
VerificationStatus.expired => l.verificationExpiredBody,
VerificationStatus.invalid => l.verificationInvalidBody,
VerificationStatus.error => l.verificationInternalBody,
@@ -174,9 +205,17 @@ class _VerificationPageState extends State<VerificationPage> {
color: colors.primary,
),
),
if (demo) ...[
const SizedBox(height: 12),
Text(
l.verificationDemo,
style: theme.textTheme.bodyMedium,
),
],
const SizedBox(height: 12),
Semantics(
liveRegion: true,
header: true,
child: Text(
title,
key: ValueKey('verification-${status.name}'),
@@ -192,20 +231,62 @@ class _VerificationPageState extends State<VerificationPage> {
),
),
],
if (status == VerificationStatus.sent) ...[
const SizedBox(height: 16),
if (sent) ...[
const SizedBox(height: 20),
Text(
l.verificationHint,
style: theme.textTheme.bodyMedium,
l.verificationRecipient,
style: theme.textTheme.labelLarge,
),
Text(
_recipient ?? _email.text,
key: const ValueKey('verification-recipient'),
),
TextButton(
key: const ValueKey('verification-change-email'),
onPressed: _changeEmail,
child: Text(l.verificationChangeEmail),
),
if (demo)
FilledButton(
key: const ValueKey('demo-confirmation'),
onPressed: () => context.goNamed(
AppRoutes.verifyEmail,
queryParameters: {'token': 'demo-valid'},
),
child: Text(
l.verificationDemoLink,
textAlign: TextAlign.center,
),
),
const SizedBox(height: 12),
OutlinedButton(
key: const ValueKey('verification-resend'),
onPressed: _send,
child: Text(
demo ? l.demoResend : l.verificationResend,
textAlign: TextAlign.center,
),
),
if (!demo) ...[
const SizedBox(height: 16),
Text(
l.verificationHint,
style: theme.textTheme.bodyMedium,
),
],
],
if (!verified && status != VerificationStatus.checking) ...[
if (!verified &&
!sent &&
!retryConfirmation &&
status != VerificationStatus.checking) ...[
const SizedBox(height: 28),
Form(
key: _form,
child: TextFormField(
key: const ValueKey('verification-email'),
controller: _email,
focusNode: _emailFocus,
autocorrect: false,
enabled: !state.isLoading,
keyboardType: TextInputType.emailAddress,
autofillHints: const [AutofillHints.email],
@@ -227,46 +308,28 @@ class _VerificationPageState extends State<VerificationPage> {
child: Text(
state.isLoading
? l.authWorking
: status == VerificationStatus.sent
? l.verificationResend
: l.verificationRequest,
textAlign: TextAlign.center,
),
),
],
if (status == VerificationStatus.error &&
!_lastWasSend &&
widget.token != null) ...[
if (retryConfirmation) ...[
const SizedBox(height: 12),
OutlinedButton(
FilledButton(
key: const ValueKey('verification-retry'),
onPressed: () => _provider.confirm(widget.token!),
child: Text(l.verificationRetry),
),
TextButton(
onPressed: _changeEmail,
child: Text(l.verificationRequest),
),
],
const SizedBox(height: 16),
TextButton(
onPressed: () => context.goNamed(AppRoutes.login),
child: Text(l.verificationBack),
),
if (demo) ...[
const SizedBox(height: 24),
Text(
l.verificationDemo,
style: theme.textTheme.bodySmall?.copyWith(
color: colors.onSurfaceVariant,
),
),
if (status == VerificationStatus.sent)
TextButton(
key: const ValueKey('demo-confirmation'),
onPressed: () => context.goNamed(
AppRoutes.verifyEmail,
queryParameters: {'token': 'demo-valid'},
),
child: Text(l.verificationDemoLink),
),
],
],
),
),
+7
View File
@@ -354,6 +354,13 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.4.1"
sxchnge_analytics:
dependency: "direct main"
description:
path: "../packages/sxchnge_analytics"
relative: true
source: path
version: "0.1.0"
sxchnge_auth:
dependency: "direct main"
description:
+2
View File
@@ -6,6 +6,8 @@ environment:
sdk: '>=3.13.0 <4.0.0'
flutter: '>=3.47.0'
dependencies:
sxchnge_analytics:
path: ../packages/sxchnge_analytics
flutter:
sdk: flutter
sxchnge_auth:
+1 -1
View File
@@ -129,7 +129,7 @@ void main() {
same(router),
);
expect(find.byKey(const ValueKey('verification-verified')), findsOneWidget);
expect(find.text('Ваша почта\nподтверждена.'), findsOneWidget);
expect(find.text('Демоподтверждение\nзавершено.'), findsOneWidget);
});
testWidgets(
+193
View File
@@ -0,0 +1,193 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:go_router/go_router.dart';
import 'package:sxchnge_analytics/sxchnge_analytics.dart';
import 'package:sxchnge_auth/sxchnge_auth.dart';
import 'package:sxchnge_app/main.dart';
import 'package:sxchnge_app/routing/routes.dart';
Future<void> open(
WidgetTester tester,
String path,
DummyAnalyticsService sink,
) async {
tester.platformDispatcher.defaultRouteNameTestValue = path;
addTearDown(tester.platformDispatcher.clearDefaultRouteNameTestValue);
await tester.pumpWidget(
SxchngeApp(
analyticsService: sink,
signupService: const DummySignupService(delay: Duration.zero),
verificationService: const DummyVerificationService(delay: Duration.zero),
),
);
await tester.pumpAndSettle();
}
Future<void> tap(WidgetTester tester, Finder finder) async {
await tester.ensureVisible(finder);
await tester.tap(finder);
await tester.pumpAndSettle();
}
void main() {
testWidgets(
'recipient change removes sent claim, focuses editor and requires a new send',
(tester) async {
final sink = DummyAnalyticsService();
await open(tester, '/verification-sent', sink);
final email = find.byKey(const ValueKey('verification-email'));
await tester.enterText(email, 'alex@example.com');
await tap(tester, find.byKey(const ValueKey('verification-resend')));
expect(email, findsNothing);
expect(
find.byKey(const ValueKey('verification-recipient')),
findsOneWidget,
);
await tap(
tester,
find.byKey(const ValueKey('verification-change-email')),
);
expect(find.byKey(const ValueKey('verification-sent')), findsNothing);
final editable = tester.widget<EditableText>(
find.descendant(of: email, matching: find.byType(EditableText)),
);
expect(editable.focusNode.hasFocus, isTrue);
await tester.enterText(email, 'different@example.com');
expect(
find.byKey(const ValueKey('verification-recipient')),
findsNothing,
);
await tester.testTextInput.receiveAction(TextInputAction.send);
await tester.pumpAndSettle();
expect(find.text('different@example.com'), findsOneWidget);
expect(
sink.events
.where(
(e) =>
e.step == JourneyStep.emailDelivery &&
e.signal == JourneySignal.requested,
)
.length,
2,
);
},
);
testWidgets(
'confirmation error has primary retry and keyboard-accessible new-email recovery',
(tester) async {
final semantics = tester.ensureSemantics();
final sink = DummyAnalyticsService();
await open(tester, '/verify-email?token=demo-internal', sink);
expect(
tester.widget(find.byKey(const ValueKey('verification-retry'))),
isA<FilledButton>(),
);
expect(find.byKey(const ValueKey('verification-email')), findsNothing);
final node = tester.getSemantics(
find.byKey(const ValueKey('verification-error')),
);
expect(node.getSemanticsData().flagsCollection.isHeader, isTrue);
expect(node.getSemanticsData().flagsCollection.isLiveRegion, isTrue);
await tap(tester, find.text('Send a new verification email'));
await tester.enterText(
find.byKey(const ValueKey('verification-email')),
'alex@example.com',
);
await tester.sendKeyEvent(LogicalKeyboardKey.tab);
await tester.pump();
await tester.sendKeyEvent(LogicalKeyboardKey.enter);
await tester.pumpAndSettle();
expect(find.byKey(const ValueKey('verification-sent')), findsOneWidget);
expect(sink.events.map((e) => e.journeyId).toSet().length, 1);
semantics.dispose();
},
);
testWidgets(
'signup to demo verification is correlated and never claims a business success',
(tester) async {
final sink = DummyAnalyticsService();
await open(tester, '/', sink);
await tap(tester, find.text('Sign up'));
await tester.enterText(find.byKey(const ValueKey('name')), 'Alex');
await tester.enterText(
find.byKey(const ValueKey('email')),
'alex@example.com',
);
await tester.enterText(
find.byKey(const ValueKey('password')),
'example-password',
);
await tester.enterText(
find.byKey(const ValueKey('confirmation')),
'example-password',
);
await tap(tester, find.byKey(const ValueKey('submit')));
expect(find.text('Demo email prepared.'), findsOneWidget);
expect(find.textContaining('We’ve sent you'), findsNothing);
await tap(tester, find.byKey(const ValueKey('demo-confirmation')));
expect(find.text('Demo verification\ncomplete.'), findsOneWidget);
expect(sink.events.map((e) => e.journeyId).toSet().length, 1);
expect(sink.events.where((e) => e.terminal).length, 1);
expect(
sink.events.where((e) => e.signal == JourneySignal.succeeded),
isEmpty,
);
expect(sink.events.every((e) => e.simulated), isTrue);
final payload = sink.events.map((e) => e.properties).toString();
for (final secret in [
'alex@example.com',
'example-password',
'Alex',
'demo-valid',
]) {
expect(payload, isNot(contains(secret)));
}
final count = sink.events.length;
await tester.pumpAndSettle();
expect(sink.events.length, count);
},
);
testWidgets(
'exit from a deep-linked failed confirmation records abandonment once',
(tester) async {
final sink = DummyAnalyticsService();
await open(tester, '/verify-email?token=demo-expired', sink);
await tap(tester, find.text('Back to login'));
expect(sink.events.last.signal, JourneySignal.abandoned);
expect(sink.events.last.exit, JourneyExit.navigation);
expect(sink.events.where((e) => e.terminal).length, 1);
final router = GoRouter.of(tester.element(find.byType(Scaffold)));
router.goNamed(AppRoutes.login);
await tester.pumpAndSettle();
expect(sink.events.where((e) => e.terminal).length, 1);
},
);
testWidgets(
'verification controls have accessible labels and mobile tap targets',
(tester) async {
final semantics = tester.ensureSemantics();
tester.view.physicalSize = const Size(390, 844);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
await open(
tester,
'/verify-email?token=demo-internal',
DummyAnalyticsService(),
);
await expectLater(tester, meetsGuideline(labeledTapTargetGuideline));
await expectLater(tester, meetsGuideline(androidTapTargetGuideline));
semantics.dispose();
},
);
}
@@ -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);
},
);
}
+5
View File
@@ -61,3 +61,8 @@ When a supported sxchnge BFF contract exists, implement transport DTOs and mappe
`VerificationService` exposes `send(email, locale)` and `confirm(token)` as named-argument methods. Inject an implementation into `VerificationProvider`, shared by web and mobile. The provider exposes immutable `VerificationState` (idle/sending/sent/checking/verified/expired/invalid/error), suppresses concurrent duplicate operations, and ignores completions after reset or disposal. Typed `VerificationException` maps confirmation failures; unknown exceptions become internal errors without exposing exception text. Sending failures always become the general error state.
`DummyVerificationService` supports `demo-valid`, `demo-expired`, and `demo-internal` confirmation tokens; other tokens are invalid. Configure `delay` and `sendFailure` for tests. Results are explicitly simulated. It performs no network, storage, mail delivery, or account mutation. A production adapter must implement backend validation, delivery, rate limits, and safe token handling; this client provider is not an authorization boundary.
`InstrumentedAuthServices` decorates the three auth service interfaces with a
shared `JourneyRecorder` from `sxchnge_analytics`. It preserves results/exceptions
for providers while emitting only typed semantic outcomes. `SimulationInfo`
allows decorators and UI to identify preview services before the first result.
@@ -17,9 +17,7 @@ class VerificationProvider extends ChangeNotifier {
VerificationStatus.sent,
);
Future<void> confirm(String token) => _run(
() => token.trim().isEmpty
? Future.error(const VerificationException(VerificationError.invalid))
: service.confirm(token: token),
() => service.confirm(token: token),
VerificationStatus.checking,
VerificationStatus.verified,
);
@@ -1,9 +1,12 @@
import '../models/auth.dart';
import 'contracts.dart';
import 'verification.dart';
/// Deterministic local simulation. No network, storage, credentials or sessions
/// are retained. Configure failures explicitly rather than using magic emails.
class DummyLoginService implements LoginService {
class DummyLoginService implements LoginService, SimulationInfo {
@override
bool get isSimulated => true;
const DummyLoginService({
this.delay = const Duration(milliseconds: 600),
this.failure,
@@ -19,7 +22,9 @@ class DummyLoginService implements LoginService {
}
}
class DummySignupService implements SignupService {
class DummySignupService implements SignupService, SimulationInfo {
@override
bool get isSimulated => true;
const DummySignupService({
this.delay = const Duration(milliseconds: 600),
this.failure,
@@ -0,0 +1,117 @@
import 'package:sxchnge_analytics/sxchnge_analytics.dart';
import '../models/auth.dart';
import '../models/verification.dart';
import 'contracts.dart';
import 'verification.dart';
/// Decorates domain services, so widget rebuilds never produce analytics events.
class InstrumentedAuthServices
implements
LoginService,
SignupService,
VerificationService,
SimulationInfo {
InstrumentedAuthServices({
required this.recorder,
required this.loginService,
required this.signupService,
required this.verificationService,
});
final JourneyRecorder recorder;
final LoginService loginService;
final SignupService signupService;
final VerificationService verificationService;
@override
bool get isSimulated =>
verificationService is SimulationInfo &&
(verificationService as SimulationInfo).isSimulated;
Future<T> _run<T>(
JourneyKind kind,
JourneyStep step,
Future<T> Function() action,
bool Function(T) simulated, {
bool terminal = false,
bool onboarding = false,
bool simulation = false,
}) async {
final operation = recorder.begin(
kind,
step,
onboarding: onboarding,
simulated: simulation,
);
try {
final result = await action();
recorder.finish(
operation,
simulated: simulated(result),
terminal: terminal,
);
return result;
} catch (error) {
final failure = switch (error) {
AuthException(code: final code) => switch (code) {
AuthError.invalidCredentials => JourneyFailure.invalidCredentials,
AuthError.emailInUse => JourneyFailure.emailInUse,
AuthError.unavailable => JourneyFailure.unavailable,
AuthError.unexpected => JourneyFailure.unexpected,
},
VerificationException(code: final code) => switch (code) {
VerificationError.expired => JourneyFailure.expired,
VerificationError.invalid => JourneyFailure.invalid,
VerificationError.internal => JourneyFailure.internal,
},
_ => JourneyFailure.unexpected,
};
recorder.finish(operation, failure: failure);
rethrow;
}
}
bool _simulation(Object service) =>
service is SimulationInfo && service.isSimulated;
@override
Future<AuthResult> login(LoginRequest request) => _run(
JourneyKind.login,
JourneyStep.login,
() => loginService.login(request),
(r) => r.isSimulated,
terminal: true,
simulation: _simulation(loginService),
);
@override
Future<AuthResult> signup(SignupRequest request) => _run(
JourneyKind.signup,
JourneyStep.signup,
() => signupService.signup(request),
(r) => r.isSimulated,
simulation: _simulation(signupService),
);
@override
Future<VerificationResult> send({
required String email,
required String locale,
}) => _run(
JourneyKind.verification,
JourneyStep.emailDelivery,
() => verificationService.send(email: email, locale: locale),
(r) => r.isSimulated,
onboarding: true,
simulation: isSimulated,
);
@override
Future<VerificationResult> confirm({required String token}) => _run(
JourneyKind.verification,
JourneyStep.emailConfirmation,
() => token.trim().isEmpty
? Future.error(const VerificationException(VerificationError.invalid))
: verificationService.confirm(token: token),
(r) => r.isSimulated,
terminal: true,
onboarding: true,
simulation: isSimulated,
);
}
@@ -1,5 +1,9 @@
import '../models/verification.dart';
abstract interface class SimulationInfo {
bool get isSimulated;
}
abstract interface class VerificationService {
Future<VerificationResult> send({
required String email,
@@ -9,11 +13,13 @@ abstract interface class VerificationService {
}
/// Explicit preview tokens only. Never use this implementation in production.
class DummyVerificationService implements VerificationService {
class DummyVerificationService implements VerificationService, SimulationInfo {
const DummyVerificationService({
this.delay = const Duration(milliseconds: 600),
this.sendFailure,
});
@override
bool get isSimulated => true;
final Duration delay;
final VerificationError? sendFailure;
@@ -10,3 +10,5 @@ export 'src/services/dummy.dart';
export 'src/models/verification.dart';
export 'src/providers/verification.dart';
export 'src/services/verification.dart';
export 'src/services/instrumented.dart';
@@ -6,6 +6,8 @@ environment:
sdk: '>=3.13.0 <4.0.0'
flutter: '>=3.47.0'
dependencies:
sxchnge_analytics:
path: ../sxchnge_analytics
flutter:
sdk: flutter
dev_dependencies: