From f1cbf642825b40ad1a3b0c407048f8e733ace560 Mon Sep 17 00:00:00 2001 From: Stephan D Date: Sat, 12 Sep 2026 08:36:45 +0200 Subject: [PATCH] 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. --- Makefile | 1 + docs/implementation.md | 11 + docs/ux/auth-journeys.md | 147 +++++++++++ frontend/app/README.md | 9 + frontend/app/lib/auth/form.dart | 16 +- .../lib/generated/i18n/app_localizations.dart | 44 +++- .../generated/i18n/app_localizations_en.dart | 25 +- .../generated/i18n/app_localizations_ru.dart | 25 +- frontend/app/lib/l10n/en.arb | 11 +- frontend/app/lib/l10n/ru.arb | 11 +- frontend/app/lib/main.dart | 42 ++- frontend/app/lib/verification/page.dart | 135 +++++++--- frontend/app/pubspec.lock | 7 + frontend/app/pubspec.yaml | 2 + frontend/app/test/routing_test.dart | 2 +- frontend/app/test/ux_analytics_test.dart | 193 ++++++++++++++ .../packages/sxchnge_analytics/.gitignore | 3 + frontend/packages/sxchnge_analytics/README.md | 21 ++ .../lib/sxchnge_analytics.dart | 245 ++++++++++++++++++ .../packages/sxchnge_analytics/pubspec.yaml | 9 + .../test/analytics_test.dart | 116 +++++++++ frontend/packages/sxchnge_auth/README.md | 5 + .../lib/src/providers/verification.dart | 4 +- .../sxchnge_auth/lib/src/services/dummy.dart | 9 +- .../lib/src/services/instrumented.dart | 117 +++++++++ .../lib/src/services/verification.dart | 8 +- .../sxchnge_auth/lib/sxchnge_auth.dart | 2 + frontend/packages/sxchnge_auth/pubspec.yaml | 2 + 28 files changed, 1158 insertions(+), 64 deletions(-) create mode 100644 docs/ux/auth-journeys.md create mode 100644 frontend/app/test/ux_analytics_test.dart create mode 100644 frontend/packages/sxchnge_analytics/.gitignore create mode 100644 frontend/packages/sxchnge_analytics/README.md create mode 100644 frontend/packages/sxchnge_analytics/lib/sxchnge_analytics.dart create mode 100644 frontend/packages/sxchnge_analytics/pubspec.yaml create mode 100644 frontend/packages/sxchnge_analytics/test/analytics_test.dart create mode 100644 frontend/packages/sxchnge_auth/lib/src/services/instrumented.dart diff --git a/Makefile b/Makefile index 4d6eccc..f1970ca 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,7 @@ .PHONY: test-frontend test-frontend: + cd frontend/packages/sxchnge_analytics && flutter test cd frontend/packages/sxchnge_auth && flutter test cd frontend/packages/sxchnge_theme && flutter test cd frontend/packages/sxchnge_theme/example && flutter test diff --git a/docs/implementation.md b/docs/implementation.md index a9410ec..e2b1181 100644 --- a/docs/implementation.md +++ b/docs/implementation.md @@ -106,3 +106,14 @@ Delivery validation passed: 56 frontend tests; formatting and all four Flutter analyses; 3 website tests, Astro check (zero errors/warnings), and website build. The app web build and browser checks passed during the immediately preceding routing task; delivery changed documentation and Git organization only. + +## Onboarding UX fixes and analytics preparation + +Task [#12](https://git.sendico.io/tech/sxchnge/issues/12) corrects the four UX +review findings: measurable submitted journeys, accurate sent recipient state, +primary retry on confirmation errors, and prominent/truthful simulated outcomes. +The new shared `sxchnge_analytics` package supplies typed events, a bounded dummy +sink and a journey recorder; auth service decorators keep collection independent +of widgets. A PostHog adapter can replace the sink later; no actual SDK or data +transmission is included. The [journey contract](ux/auth-journeys.md) records event +semantics, metrics, privacy constraints, windows, validation and limitations. diff --git a/docs/ux/auth-journeys.md b/docs/ux/auth-journeys.md new file mode 100644 index 0000000..2ddaaed --- /dev/null +++ b/docs/ux/auth-journeys.md @@ -0,0 +1,147 @@ +# Authentication UX and analytics contract + +Task: [#12](https://git.sendico.io/tech/sxchnge/issues/12). Applies to login, +signup through email verification, and standalone confirmation links. + +## User outcomes and interface + +Signup leads to email delivery, then confirmation. Sent status shows a fixed +recipient; Change email explicitly leaves that state and focuses the editor. +A temporary confirmation failure prioritizes Retry verification, with requesting +a replacement email secondary. Expired/invalid links offer replacement delivery. +Demo labels sit beside the heading, and demo outcomes explicitly state that no +email was delivered or account verified. The demo confirmation link is the +primary next step on the simulated sent screen. EN/RU use the installed theme. + +## Measurement scope and attempts + +Owner/consumer: the sxchnge product team, diagnosing onboarding completion and +choosing between fixes to registration, delivery and confirmation recovery. + +A journey starts at the first **locally valid service request**. This intentionally +measures submitted attempts, not page views or field editing; pre-submit form +abandonment and validation friction are outside this denominator. No generic +click/field events are collected. + +- `login`: one requested login through its service result. +- `signup`: signup acceptance, email delivery requests, then email confirmation. +- `verification`: confirmation/replacement delivery when there is no active signup. + +Retries reuse the journey ID and get a new operation number. Signup success and +email delivery are intermediate states; confirmation success is the onboarding +terminal outcome. Login success is terminal. Failed operations remain recoverable. +Every operation has one completion, and every journey at most one terminal event. +Changing auth mode, returning from verification to login, changing a confirmation +token, or disposing the app ends the open attempt explicitly. A new operation +after a terminal outcome starts a new journey. + +The inactivity window is **30 minutes after the last settled nonterminal service +result**, reset by a new request. It is suspended while a service operation is +pending: missing completion is not assumed to be abandonment. An explicit exit +can end an attempt while work is pending; it does not cancel backend work. Late +results cannot reopen that attempt or emit success after its abandonment. + +IDs are random, scoped to this app instance and never derived from identity or +link tokens. Same-instance signup → demo link is correlated. A real email opened +in another tab/device or after reload starts a standalone verification journey. +Cross-device signup conversion needs a future backend-issued, non-sensitive +correlation contract; client code must never use email or the token as that ID. +Tab termination is not reliably observable. Missing exits remain **unknown**, +not inferred abandonment. Background timer throttling can delay idle detection. + +## Event schema (version 1) + +| Event | Emission condition | Product decision supported | +| --- | --- | --- | +| `auth_journey_started` | First valid request for a new attempt | Submitted-attempt denominator | +| `auth_{step}_requested` | A domain service operation starts | Locate the last meaningful step and retries | +| `auth_{step}_succeeded` | Service confirms a non-simulated result | Step conversion and outcome duration | +| `auth_{step}_simulated` | Dummy result, or terminal outcome of a mixed simulated journey | Validate the preview funnel without reporting business conversion | +| `auth_{step}_failed` | Service operation fails | Failure rates by safe error category and recovery | +| `auth_journey_abandoned` | Explicit exit or settled inactivity timeout | Diagnose known abandonment/drop-off | + +`step` is `login`, `signup`, `email_delivery`, or `email_confirmation`. +Properties: schema_version, event_id, journey_id, journey_kind, sequence, +operation (per-journey counter when applicable), occurred_at (UTC), elapsed_ms +(monotonic journey duration), terminal, simulated, and optional typed failure/exit. +Every event in a known dummy journey is marked simulated. No email, password, +name, user ID, confirmation token, URL, raw exception, or form contents is captured. + +## Metrics + +Group by journey_kind and journey_id; deduplicate delivery by event_id, ordering +by sequence. Use **closed attempts** (terminal outcome or explicit abandonment) +for settled cohort rates; report open/unknown counts separately. For a fixed +reporting cohort, include starts in that interval and allow their defined +inactivity window to settle. Report pending operations separately even after it. + +- Completion: distinct successful terminal journey IDs / closed journey IDs. +- Simulated completion: same calculation using simulated terminals, reported + separately. Never mix these into actual account/session conversion. +- Failure rate: distinct failed operations / requested operations; also report + journeys with any failure / started journeys. +- Recovery: closed journeys completing after a failed operation / closed + journeys with a failed operation. +- Known abandonment: abandoned journey IDs / closed journey IDs, by exit reason. +- Step drop-off: closed journeys reaching a step but not the next / closed + journeys reaching that step (signup acceptance → delivery → confirmation). +- Duration: terminal elapsed_ms; step latency from matching requested/result + operation timestamps. Report distributions separately for retries and failures. + +Current service results are client evidence only. Future production adapters must +return success only after server confirmation; delivery API acceptance still +isn't proof that the email reached an inbox. That remains a documented proxy. + +## Dummy service and PostHog replacement + +`sxchnge_analytics` is a platform-independent shared package. `AnalyticsService` +accepts typed `AnalyticsEvent`; `DummyAnalyticsService` retains the latest 500 +unique events in memory for tests/debugging. It exposes an immutable event list +and clear(), writes no logs/files, installs no SDK, and makes no network requests. +It is diagnostic storage, not a durable metrics backend; older events are evicted. + +Inject the service through `SxchngeApp(analyticsService: service)`. Auth services +are decorated by `InstrumentedAuthServices`, so rebuilds, locale changes and +layout changes cannot generate events. Analytics exceptions are swallowed at the +recorder boundary and analytics futures are never awaited by auth operations. +Adapters must do lightweight capture/enqueue work; synchronous CPU blocking in an +adapter cannot be isolated by a Dart Future. + +A future PostHog adapter should implement capture using event.name and the +allowlisted event.properties. Official API reference: +https://pub.dev/documentation/posthog_flutter/latest/posthog_flutter/Posthog-class.html + +Preserve event_id and sequence for deduplication/reconstruction. Configure consent, +retention, region and anonymous identity before enabling collection. Do not enable +autocapture, session replay, automatic URL capture or user identification as a +side effect of this adapter: confirmation URLs and authentication forms contain +sensitive data. No PostHog key or network integration is included in this task. +Retries/buffering in a future adapter must preserve the original event_id and +occurred_at, remain bounded, and never delay authentication. + +## UX review and verification + +- Purpose/primary action: explicit demo outcomes and primary confirmation retry. +- Context/recovery: confirmed recipient separate from editor, focus restored by + Change email, no sent claim while editing; service failure retains input. +- States: initial/loading/sent/verified/expired/invalid/internal error remain + available. Restricted/offline backend behavior is outside this dummy service. +- Responsiveness: EN/RU and 200% text coverage retained; focused tests cover + keyboard submission, semantic heading/live region and mobile target labels/sizes. +- Measurement: tests cover correlated success, simulated outcomes, failures, + retries, duplicate completion/delivery, explicit exit, pending operations, + inactivity, and throwing/failing/non-completing analytics adapters. +- Privacy: payload tests exclude supplied form values and tokens; only typed + allowlisted properties are exposed. Dummy buffer is bounded and read-only. +- Remaining limits: no real PostHog delivery or cross-device correlation is + claimed. Automated semantics checks do not replace assistive-technology testing + on supported devices; a native VoiceOver/TalkBack audit remains a release check. + +### Recorded validation + +The automated suite passes 68 tests: 7 analytics, 20 auth, 3 theme, 3 theme +consumer, and 35 app tests. Formatting and analysis pass for the three affected +packages; the web build passes. Browser walkthrough at 390px covers internal-error +hierarchy, secondary email recovery, actual Tab/Enter submission, and the demo +sent screen. The heading is exposed as a live accessibility heading. Recipient +text uses a normal text node to remain exposed to assistive technology. diff --git a/frontend/app/README.md b/frontend/app/README.md index b23fbd3..8871f74 100644 --- a/frontend/app/README.md +++ b/frontend/app/README.md @@ -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. diff --git a/frontend/app/lib/auth/form.dart b/frontend/app/lib/auth/form.dart index 4a2f43e..d0781cb 100644 --- a/frontend/app/lib/auth/form.dart +++ b/frontend/app/lib/auth/form.dart @@ -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 { void _switch(bool signUp) { if (signUp == _signUp) return; + context.read().abandon(JourneyExit.modeChange); context.read().reset(); context.read().reset(); _form.currentState?.reset(); @@ -130,6 +133,11 @@ class _AuthFormState extends State { ), ), ), + 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 { ), ], ), - const SizedBox(height: 24), - const Divider(), - const SizedBox(height: 16), - Text( - l.earlyPreview, - textAlign: TextAlign.center, - style: text.bodySmall!.copyWith(color: colors.onSurfaceVariant), - ), ], ); } diff --git a/frontend/app/lib/generated/i18n/app_localizations.dart b/frontend/app/lib/generated/i18n/app_localizations.dart index 0da3b48..69df6ec 100644 --- a/frontend/app/lib/generated/i18n/app_localizations.dart +++ b/frontend/app/lib/generated/i18n/app_localizations.dart @@ -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 diff --git a/frontend/app/lib/generated/i18n/app_localizations_en.dart b/frontend/app/lib/generated/i18n/app_localizations_en.dart index 2627c5a..8972729 100644 --- a/frontend/app/lib/generated/i18n/app_localizations_en.dart +++ b/frontend/app/lib/generated/i18n/app_localizations_en.dart @@ -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'; } diff --git a/frontend/app/lib/generated/i18n/app_localizations_ru.dart b/frontend/app/lib/generated/i18n/app_localizations_ru.dart index 3a76c19..9c32b10 100644 --- a/frontend/app/lib/generated/i18n/app_localizations_ru.dart +++ b/frontend/app/lib/generated/i18n/app_localizations_ru.dart @@ -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 => 'Подготовить демописьмо ещё раз'; } diff --git a/frontend/app/lib/l10n/en.arb b/frontend/app/lib/l10n/en.arb index 4ad63ce..56fa545 100644 --- a/frontend/app/lib/l10n/en.arb +++ b/frontend/app/lib/l10n/en.arb @@ -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" } diff --git a/frontend/app/lib/l10n/ru.arb b/frontend/app/lib/l10n/ru.arb index 63ca5b3..02bf9e9 100644 --- a/frontend/app/lib/l10n/ru.arb +++ b/frontend/app/lib/l10n/ru.arb @@ -60,10 +60,17 @@ "verificationInternalBody": "Не удалось выполнить запрос из-за внутренней ошибки. Попробуйте ещё раз.", "verificationResend": "Отправить письмо ещё раз", "verificationRequest": "Отправить новое письмо", - "verificationRetry": "Попробовать ещё раз", + "verificationRetry": "Повторить подтверждение", "verificationBack": "Вернуться ко входу", "verificationHint": "Нет письма? Проверьте папку «Спам» или запросите новое ниже.", "verificationDemo": "ДЕМО · Письмо не отправляется, реальный аккаунт не подтверждается.", "verificationDemoLink": "Открыть демоссылку подтверждения", - "verificationStep": "ПОДТВЕРДИТЕ ПОЧТУ" + "verificationStep": "ПОДТВЕРДИТЕ ПОЧТУ", + "demoSentHeading": "Демописьмо готово.", + "demoSentBody": "Письмо не отправлено. Используйте демоссылку ниже, чтобы посмотреть следующий шаг.", + "demoVerifiedHeading": "Демоподтверждение\nзавершено.", + "demoVerifiedBody": "Вы посмотрели подтверждение почты. Реальный аккаунт не подтверждён. Вернитесь ко входу, чтобы продолжить знакомство с демоверсией.", + "verificationRecipient": "Получатель", + "verificationChangeEmail": "Изменить почту", + "demoResend": "Подготовить демописьмо ещё раз" } diff --git a/frontend/app/lib/main.dart b/frontend/app/lib/main.dart index 06c501e..032cb6f 100644 --- a/frontend/app/lib/main.dart +++ b/frontend/app/lib/main.dart @@ -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 { 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 { @override Widget build(BuildContext context) => MultiProvider( providers: [ + Provider.value(value: _journeys), ChangeNotifierProvider.value(value: _locale), - Provider.value(value: widget.verificationService), - ChangeNotifierProvider(create: (_) => LoginProvider(widget.loginService)), - ChangeNotifierProvider( - create: (_) => SignupProvider(widget.signupService), - ), + Provider.value(value: _services), + ChangeNotifierProvider(create: (_) => LoginProvider(_services)), + ChangeNotifierProvider(create: (_) => SignupProvider(_services)), ], child: Consumer( builder: (context, locale, _) => MaterialApp.router( diff --git a/frontend/app/lib/verification/page.dart b/frontend/app/lib/verification/page.dart index 28d5acb..718f936 100644 --- a/frontend/app/lib/verification/page.dart +++ b/frontend/app/lib/verification/page.dart @@ -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 { late final _provider = VerificationProvider( context.read(), ); + final _emailFocus = FocusNode(); + String? _recipient; bool _initialized = false; bool _lastWasSend = false; bool _validated = false; @@ -56,6 +60,7 @@ class _VerificationPageState extends State { void didUpdateWidget(covariant VerificationPage oldWidget) { super.didUpdateWidget(oldWidget); if (oldWidget.token == widget.token) return; + context.read().abandon(JourneyExit.navigation); _provider.reset(); _lastWasSend = false; _validated = false; @@ -68,7 +73,12 @@ class _VerificationPageState extends State { 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 { ); } + 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 { ].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 { 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 { ), ), ], - 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 { 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), - ), - ], ], ), ), diff --git a/frontend/app/pubspec.lock b/frontend/app/pubspec.lock index 0374cb1..859f421 100644 --- a/frontend/app/pubspec.lock +++ b/frontend/app/pubspec.lock @@ -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: diff --git a/frontend/app/pubspec.yaml b/frontend/app/pubspec.yaml index 97eec43..d02e0ea 100644 --- a/frontend/app/pubspec.yaml +++ b/frontend/app/pubspec.yaml @@ -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: diff --git a/frontend/app/test/routing_test.dart b/frontend/app/test/routing_test.dart index 6d8d645..5d8ed39 100644 --- a/frontend/app/test/routing_test.dart +++ b/frontend/app/test/routing_test.dart @@ -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( diff --git a/frontend/app/test/ux_analytics_test.dart b/frontend/app/test/ux_analytics_test.dart new file mode 100644 index 0000000..0f42221 --- /dev/null +++ b/frontend/app/test/ux_analytics_test.dart @@ -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 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 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( + 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(), + ); + 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(); + }, + ); +} diff --git a/frontend/packages/sxchnge_analytics/.gitignore b/frontend/packages/sxchnge_analytics/.gitignore new file mode 100644 index 0000000..61608ef --- /dev/null +++ b/frontend/packages/sxchnge_analytics/.gitignore @@ -0,0 +1,3 @@ +.dart_tool/ +build/ +pubspec.lock diff --git a/frontend/packages/sxchnge_analytics/README.md b/frontend/packages/sxchnge_analytics/README.md new file mode 100644 index 0000000..9ae9fcf --- /dev/null +++ b/frontend/packages/sxchnge_analytics/README.md @@ -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. diff --git a/frontend/packages/sxchnge_analytics/lib/sxchnge_analytics.dart b/frontend/packages/sxchnge_analytics/lib/sxchnge_analytics.dart new file mode 100644 index 0000000..b638a2e --- /dev/null +++ b/frontend/packages/sxchnge_analytics/lib/sxchnge_analytics.dart @@ -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 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 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 _events = Queue(); + List get events => List.unmodifiable(_events); + void clear() => _events.clear(); + @override + Future 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.sync(() => service.capture(event)).catchError((Object _) {}), + ); + } + + void dispose() { + abandon(JourneyExit.disposed); + _disposed = true; + _idle?.cancel(); + } +} diff --git a/frontend/packages/sxchnge_analytics/pubspec.yaml b/frontend/packages/sxchnge_analytics/pubspec.yaml new file mode 100644 index 0000000..7ed4e88 --- /dev/null +++ b/frontend/packages/sxchnge_analytics/pubspec.yaml @@ -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 diff --git a/frontend/packages/sxchnge_analytics/test/analytics_test.dart b/frontend/packages/sxchnge_analytics/test/analytics_test.dart new file mode 100644 index 0000000..7dadc3a --- /dev/null +++ b/frontend/packages/sxchnge_analytics/test/analytics_test.dart @@ -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 capture(AnalyticsEvent event) { + if (mode == 0) throw StateError('offline'); + if (mode == 1) return Future.error(StateError('offline')); + return Completer().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.delayed(const Duration(milliseconds: 25)); + expect(sink.events.any((e) => e.terminal), isFalse); + recorder.finish(op, failure: JourneyFailure.unavailable); + await Future.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.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); + }, + ); +} diff --git a/frontend/packages/sxchnge_auth/README.md b/frontend/packages/sxchnge_auth/README.md index 79eca2b..b804c0d 100644 --- a/frontend/packages/sxchnge_auth/README.md +++ b/frontend/packages/sxchnge_auth/README.md @@ -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. diff --git a/frontend/packages/sxchnge_auth/lib/src/providers/verification.dart b/frontend/packages/sxchnge_auth/lib/src/providers/verification.dart index fa37f13..c6bef8c 100644 --- a/frontend/packages/sxchnge_auth/lib/src/providers/verification.dart +++ b/frontend/packages/sxchnge_auth/lib/src/providers/verification.dart @@ -17,9 +17,7 @@ class VerificationProvider extends ChangeNotifier { VerificationStatus.sent, ); Future 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, ); diff --git a/frontend/packages/sxchnge_auth/lib/src/services/dummy.dart b/frontend/packages/sxchnge_auth/lib/src/services/dummy.dart index 9b9ab82..2076781 100644 --- a/frontend/packages/sxchnge_auth/lib/src/services/dummy.dart +++ b/frontend/packages/sxchnge_auth/lib/src/services/dummy.dart @@ -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, diff --git a/frontend/packages/sxchnge_auth/lib/src/services/instrumented.dart b/frontend/packages/sxchnge_auth/lib/src/services/instrumented.dart new file mode 100644 index 0000000..f63f09e --- /dev/null +++ b/frontend/packages/sxchnge_auth/lib/src/services/instrumented.dart @@ -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 _run( + JourneyKind kind, + JourneyStep step, + Future 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 login(LoginRequest request) => _run( + JourneyKind.login, + JourneyStep.login, + () => loginService.login(request), + (r) => r.isSimulated, + terminal: true, + simulation: _simulation(loginService), + ); + @override + Future signup(SignupRequest request) => _run( + JourneyKind.signup, + JourneyStep.signup, + () => signupService.signup(request), + (r) => r.isSimulated, + simulation: _simulation(signupService), + ); + @override + Future 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 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, + ); +} diff --git a/frontend/packages/sxchnge_auth/lib/src/services/verification.dart b/frontend/packages/sxchnge_auth/lib/src/services/verification.dart index d5e55e5..275e0e7 100644 --- a/frontend/packages/sxchnge_auth/lib/src/services/verification.dart +++ b/frontend/packages/sxchnge_auth/lib/src/services/verification.dart @@ -1,5 +1,9 @@ import '../models/verification.dart'; +abstract interface class SimulationInfo { + bool get isSimulated; +} + abstract interface class VerificationService { Future 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; diff --git a/frontend/packages/sxchnge_auth/lib/sxchnge_auth.dart b/frontend/packages/sxchnge_auth/lib/sxchnge_auth.dart index d669ca0..fe57c87 100644 --- a/frontend/packages/sxchnge_auth/lib/sxchnge_auth.dart +++ b/frontend/packages/sxchnge_auth/lib/sxchnge_auth.dart @@ -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'; diff --git a/frontend/packages/sxchnge_auth/pubspec.yaml b/frontend/packages/sxchnge_auth/pubspec.yaml index ec4dc61..fe046e7 100644 --- a/frontend/packages/sxchnge_auth/pubspec.yaml +++ b/frontend/packages/sxchnge_auth/pubspec.yaml @@ -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: