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..cc7ca83 100644 --- a/docs/implementation.md +++ b/docs/implementation.md @@ -106,3 +106,19 @@ 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. + +Delivery: [PR #13](https://git.sendico.io/tech/sxchnge/pulls/13), `onboarding-12`. +Implementation commit `f1cbf64`; 68 tests and affected-package analysis/formatting +plus web build passed. Final browser verification confirms accessible recipient +text and keyboard recovery. No merge or actual PostHog collection was performed. diff --git a/docs/ux/auth-journeys.md b/docs/ux/auth-journeys.md new file mode 100644 index 0000000..c1a365f --- /dev/null +++ b/docs/ux/auth-journeys.md @@ -0,0 +1,151 @@ +# 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. + +Delivered in [PR #13](https://git.sendico.io/tech/sxchnge/pulls/13), branch +`onboarding-12`. The final browser accessibility tree exposes the recipient text, +demo status, status heading and recovery actions. PR is open for review. 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: