Fix onboarding UX and prepare replaceable journey analytics #13
No files matched your search
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -4,6 +4,8 @@ import 'package:go_router/go_router.dart';
|
||||
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import 'package:sxchnge_analytics/sxchnge_analytics.dart';
|
||||
|
||||
import 'package:sxchnge_auth/sxchnge_auth.dart';
|
||||
|
||||
import '../routing/routes.dart';
|
||||
@@ -58,6 +60,7 @@ class _AuthFormState extends State<AuthForm> {
|
||||
|
||||
void _switch(bool signUp) {
|
||||
if (signUp == _signUp) return;
|
||||
context.read<JourneyRecorder>().abandon(JourneyExit.modeChange);
|
||||
context.read<LoginProvider>().reset();
|
||||
context.read<SignupProvider>().reset();
|
||||
_form.currentState?.reset();
|
||||
@@ -130,6 +133,11 @@ class _AuthFormState extends State<AuthForm> {
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
l.earlyPreview,
|
||||
style: text.bodySmall!.copyWith(color: colors.onSurfaceVariant),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Text(
|
||||
_signUp ? l.signupHeading : l.loginHeading,
|
||||
@@ -316,14 +324,6 @@ class _AuthFormState extends State<AuthForm> {
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
const Divider(),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
l.earlyPreview,
|
||||
textAlign: TextAlign.center,
|
||||
style: text.bodySmall!.copyWith(color: colors.onSurfaceVariant),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -461,7 +461,7 @@ abstract class AppLocalizations {
|
||||
/// No description provided for @verificationRetry.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Try again'**
|
||||
/// **'Retry verification'**
|
||||
String get verificationRetry;
|
||||
|
||||
/// No description provided for @verificationBack.
|
||||
@@ -493,6 +493,48 @@ abstract class AppLocalizations {
|
||||
/// In en, this message translates to:
|
||||
/// **'CONFIRM YOUR EMAIL'**
|
||||
String get verificationStep;
|
||||
|
||||
/// No description provided for @demoSentHeading.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Demo email prepared.'**
|
||||
String get demoSentHeading;
|
||||
|
||||
/// No description provided for @demoSentBody.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'No email was sent. Use the demo confirmation below to preview the next step.'**
|
||||
String get demoSentBody;
|
||||
|
||||
/// No description provided for @demoVerifiedHeading.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Demo verification\ncomplete.'**
|
||||
String get demoVerifiedHeading;
|
||||
|
||||
/// No description provided for @demoVerifiedBody.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'You’ve previewed email confirmation. No account was verified. Return to login to explore the demo.'**
|
||||
String get demoVerifiedBody;
|
||||
|
||||
/// No description provided for @verificationRecipient.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Recipient'**
|
||||
String get verificationRecipient;
|
||||
|
||||
/// No description provided for @verificationChangeEmail.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Change email'**
|
||||
String get verificationChangeEmail;
|
||||
|
||||
/// No description provided for @demoResend.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Prepare demo email again'**
|
||||
String get demoResend;
|
||||
}
|
||||
|
||||
class _AppLocalizationsDelegate
|
||||
|
||||
@@ -201,7 +201,7 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
String get verificationRequest => 'Send a new verification email';
|
||||
|
||||
@override
|
||||
String get verificationRetry => 'Try again';
|
||||
String get verificationRetry => 'Retry verification';
|
||||
|
||||
@override
|
||||
String get verificationBack => 'Back to login';
|
||||
@@ -219,4 +219,27 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get verificationStep => 'CONFIRM YOUR EMAIL';
|
||||
|
||||
@override
|
||||
String get demoSentHeading => 'Demo email prepared.';
|
||||
|
||||
@override
|
||||
String get demoSentBody =>
|
||||
'No email was sent. Use the demo confirmation below to preview the next step.';
|
||||
|
||||
@override
|
||||
String get demoVerifiedHeading => 'Demo verification\ncomplete.';
|
||||
|
||||
@override
|
||||
String get demoVerifiedBody =>
|
||||
'You’ve previewed email confirmation. No account was verified. Return to login to explore the demo.';
|
||||
|
||||
@override
|
||||
String get verificationRecipient => 'Recipient';
|
||||
|
||||
@override
|
||||
String get verificationChangeEmail => 'Change email';
|
||||
|
||||
@override
|
||||
String get demoResend => 'Prepare demo email again';
|
||||
}
|
||||
@@ -202,7 +202,7 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
String get verificationRequest => 'Отправить новое письмо';
|
||||
|
||||
@override
|
||||
String get verificationRetry => 'Попробовать ещё раз';
|
||||
String get verificationRetry => 'Повторить подтверждение';
|
||||
|
||||
@override
|
||||
String get verificationBack => 'Вернуться ко входу';
|
||||
@@ -220,4 +220,27 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get verificationStep => 'ПОДТВЕРДИТЕ ПОЧТУ';
|
||||
|
||||
@override
|
||||
String get demoSentHeading => 'Демописьмо готово.';
|
||||
|
||||
@override
|
||||
String get demoSentBody =>
|
||||
'Письмо не отправлено. Используйте демоссылку ниже, чтобы посмотреть следующий шаг.';
|
||||
|
||||
@override
|
||||
String get demoVerifiedHeading => 'Демоподтверждение\nзавершено.';
|
||||
|
||||
@override
|
||||
String get demoVerifiedBody =>
|
||||
'Вы посмотрели подтверждение почты. Реальный аккаунт не подтверждён. Вернитесь ко входу, чтобы продолжить знакомство с демоверсией.';
|
||||
|
||||
@override
|
||||
String get verificationRecipient => 'Получатель';
|
||||
|
||||
@override
|
||||
String get verificationChangeEmail => 'Изменить почту';
|
||||
|
||||
@override
|
||||
String get demoResend => 'Подготовить демописьмо ещё раз';
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -60,10 +60,17 @@
|
||||
"verificationInternalBody": "Не удалось выполнить запрос из-за внутренней ошибки. Попробуйте ещё раз.",
|
||||
"verificationResend": "Отправить письмо ещё раз",
|
||||
"verificationRequest": "Отправить новое письмо",
|
||||
"verificationRetry": "Попробовать ещё раз",
|
||||
"verificationRetry": "Повторить подтверждение",
|
||||
"verificationBack": "Вернуться ко входу",
|
||||
"verificationHint": "Нет письма? Проверьте папку «Спам» или запросите новое ниже.",
|
||||
"verificationDemo": "ДЕМО · Письмо не отправляется, реальный аккаунт не подтверждается.",
|
||||
"verificationDemoLink": "Открыть демоссылку подтверждения",
|
||||
"verificationStep": "ПОДТВЕРДИТЕ ПОЧТУ"
|
||||
"verificationStep": "ПОДТВЕРДИТЕ ПОЧТУ",
|
||||
"demoSentHeading": "Демописьмо готово.",
|
||||
"demoSentBody": "Письмо не отправлено. Используйте демоссылку ниже, чтобы посмотреть следующий шаг.",
|
||||
"demoVerifiedHeading": "Демоподтверждение\nзавершено.",
|
||||
"demoVerifiedBody": "Вы посмотрели подтверждение почты. Реальный аккаунт не подтверждён. Вернитесь ко входу, чтобы продолжить знакомство с демоверсией.",
|
||||
"verificationRecipient": "Получатель",
|
||||
"verificationChangeEmail": "Изменить почту",
|
||||
"demoResend": "Подготовить демописьмо ещё раз"
|
||||
}
|
||||
@@ -2,6 +2,8 @@ import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import 'package:sxchnge_analytics/sxchnge_analytics.dart';
|
||||
|
||||
import 'package:sxchnge_auth/sxchnge_auth.dart';
|
||||
|
||||
import 'package:sxchnge_theme/sxchnge_theme.dart';
|
||||
@@ -21,11 +23,13 @@ class SxchngeApp extends StatefulWidget {
|
||||
const SxchngeApp({
|
||||
super.key,
|
||||
this.localeProvider,
|
||||
this.analyticsService,
|
||||
this.loginService = const DummyLoginService(),
|
||||
this.signupService = const DummySignupService(),
|
||||
this.verificationService = const DummyVerificationService(),
|
||||
});
|
||||
final LocaleProvider? localeProvider;
|
||||
final AnalyticsService? analyticsService;
|
||||
final LoginService loginService;
|
||||
final SignupService signupService;
|
||||
final VerificationService verificationService;
|
||||
@@ -37,11 +41,38 @@ class SxchngeApp extends StatefulWidget {
|
||||
class _SxchngeAppState extends State<SxchngeApp> {
|
||||
late final _locale = widget.localeProvider ?? LocaleProvider();
|
||||
|
||||
late final _router = createAppRouter();
|
||||
late final _journeys = JourneyRecorder(
|
||||
widget.analyticsService ?? DummyAnalyticsService(),
|
||||
);
|
||||
late final _services = InstrumentedAuthServices(
|
||||
recorder: _journeys,
|
||||
loginService: widget.loginService,
|
||||
signupService: widget.signupService,
|
||||
verificationService: widget.verificationService,
|
||||
);
|
||||
late final _router = createAppRouter()
|
||||
..routeInformationProvider.addListener(_routeChanged);
|
||||
String _previousPath = '/';
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_previousPath = _router.routeInformationProvider.value.uri.path;
|
||||
}
|
||||
|
||||
void _routeChanged() {
|
||||
final path = _router.routeInformationProvider.value.uri.path;
|
||||
if (path == '/' && _previousPath != '/') {
|
||||
_journeys.abandon(JourneyExit.navigation);
|
||||
}
|
||||
_previousPath = path;
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_router.routeInformationProvider.removeListener(_routeChanged);
|
||||
_router.dispose();
|
||||
_journeys.dispose();
|
||||
_locale.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
@@ -49,12 +80,11 @@ class _SxchngeAppState extends State<SxchngeApp> {
|
||||
@override
|
||||
Widget build(BuildContext context) => MultiProvider(
|
||||
providers: [
|
||||
Provider<JourneyRecorder>.value(value: _journeys),
|
||||
ChangeNotifierProvider.value(value: _locale),
|
||||
Provider<VerificationService>.value(value: widget.verificationService),
|
||||
ChangeNotifierProvider(create: (_) => LoginProvider(widget.loginService)),
|
||||
ChangeNotifierProvider(
|
||||
create: (_) => SignupProvider(widget.signupService),
|
||||
),
|
||||
Provider<VerificationService>.value(value: _services),
|
||||
ChangeNotifierProvider(create: (_) => LoginProvider(_services)),
|
||||
ChangeNotifierProvider(create: (_) => SignupProvider(_services)),
|
||||
],
|
||||
child: Consumer<LocaleProvider>(
|
||||
builder: (context, locale, _) => MaterialApp.router(
|
||||
|
||||
@@ -4,6 +4,8 @@ import 'package:go_router/go_router.dart';
|
||||
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import 'package:sxchnge_analytics/sxchnge_analytics.dart';
|
||||
|
||||
import 'package:sxchnge_auth/sxchnge_auth.dart';
|
||||
|
||||
import '../auth/brand_panel.dart';
|
||||
@@ -28,6 +30,8 @@ class _VerificationPageState extends State<VerificationPage> {
|
||||
late final _provider = VerificationProvider(
|
||||
context.read<VerificationService>(),
|
||||
);
|
||||
final _emailFocus = FocusNode();
|
||||
String? _recipient;
|
||||
bool _initialized = false;
|
||||
bool _lastWasSend = false;
|
||||
bool _validated = false;
|
||||
@@ -56,6 +60,7 @@ class _VerificationPageState extends State<VerificationPage> {
|
||||
void didUpdateWidget(covariant VerificationPage oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.token == widget.token) return;
|
||||
context.read<JourneyRecorder>().abandon(JourneyExit.navigation);
|
||||
_provider.reset();
|
||||
_lastWasSend = false;
|
||||
_validated = false;
|
||||
@@ -68,7 +73,12 @@ class _VerificationPageState extends State<VerificationPage> {
|
||||
void _send() {
|
||||
if (_provider.state.isLoading) return;
|
||||
_validated = true;
|
||||
if (!(_form.currentState?.validate() ?? false)) return;
|
||||
if (_provider.state.status != VerificationStatus.sent &&
|
||||
!(_form.currentState?.validate() ?? false)) {
|
||||
return;
|
||||
}
|
||||
_recipient = _email.text.trim();
|
||||
FocusScope.of(context).unfocus();
|
||||
_lastWasSend = true;
|
||||
_provider.send(
|
||||
email: _email.text,
|
||||
@@ -76,10 +86,20 @@ class _VerificationPageState extends State<VerificationPage> {
|
||||
);
|
||||
}
|
||||
|
||||
void _changeEmail() {
|
||||
_lastWasSend = true;
|
||||
_validated = false;
|
||||
_provider.reset();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) _emailFocus.requestFocus();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_provider.dispose();
|
||||
_email.dispose();
|
||||
_emailFocus.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@@ -99,21 +119,32 @@ class _VerificationPageState extends State<VerificationPage> {
|
||||
].contains(status);
|
||||
final verified = status == VerificationStatus.verified;
|
||||
final demo =
|
||||
_provider.service is DummyVerificationService || state.isSimulated;
|
||||
(_provider.service is SimulationInfo &&
|
||||
(_provider.service as SimulationInfo).isSimulated) ||
|
||||
state.isSimulated;
|
||||
final sent = status == VerificationStatus.sent;
|
||||
final retryConfirmation =
|
||||
status == VerificationStatus.error &&
|
||||
!_lastWasSend &&
|
||||
widget.token != null;
|
||||
final title = switch (status) {
|
||||
VerificationStatus.sent => l.checkInbox,
|
||||
VerificationStatus.sent => demo ? l.demoSentHeading : l.checkInbox,
|
||||
VerificationStatus.sending => l.verificationSending,
|
||||
VerificationStatus.checking => l.verificationChecking,
|
||||
VerificationStatus.verified => l.verificationVerified,
|
||||
VerificationStatus.verified =>
|
||||
demo ? l.demoVerifiedHeading : l.verificationVerified,
|
||||
VerificationStatus.expired => l.verificationExpired,
|
||||
VerificationStatus.invalid => l.verificationInvalid,
|
||||
VerificationStatus.error => l.verificationInternal,
|
||||
VerificationStatus.idle =>
|
||||
widget.token != null ? l.verificationChecking : l.verificationRequest,
|
||||
widget.token != null && !_lastWasSend
|
||||
? l.verificationChecking
|
||||
: l.verificationRequest,
|
||||
};
|
||||
final body = switch (status) {
|
||||
VerificationStatus.sent => l.verificationSent,
|
||||
VerificationStatus.verified => l.verificationVerifiedBody,
|
||||
VerificationStatus.sent => demo ? l.demoSentBody : l.verificationSent,
|
||||
VerificationStatus.verified =>
|
||||
demo ? l.demoVerifiedBody : l.verificationVerifiedBody,
|
||||
VerificationStatus.expired => l.verificationExpiredBody,
|
||||
VerificationStatus.invalid => l.verificationInvalidBody,
|
||||
VerificationStatus.error => l.verificationInternalBody,
|
||||
@@ -174,9 +205,17 @@ class _VerificationPageState extends State<VerificationPage> {
|
||||
color: colors.primary,
|
||||
),
|
||||
),
|
||||
if (demo) ...[
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
l.verificationDemo,
|
||||
style: theme.textTheme.bodyMedium,
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 12),
|
||||
Semantics(
|
||||
liveRegion: true,
|
||||
header: true,
|
||||
child: Text(
|
||||
title,
|
||||
key: ValueKey('verification-${status.name}'),
|
||||
@@ -192,20 +231,62 @@ class _VerificationPageState extends State<VerificationPage> {
|
||||
),
|
||||
),
|
||||
],
|
||||
if (status == VerificationStatus.sent) ...[
|
||||
if (sent) ...[
|
||||
const SizedBox(height: 20),
|
||||
Text(
|
||||
l.verificationRecipient,
|
||||
style: theme.textTheme.labelLarge,
|
||||
),
|
||||
Text(
|
||||
_recipient ?? _email.text,
|
||||
key: const ValueKey('verification-recipient'),
|
||||
),
|
||||
TextButton(
|
||||
key: const ValueKey('verification-change-email'),
|
||||
onPressed: _changeEmail,
|
||||
child: Text(l.verificationChangeEmail),
|
||||
),
|
||||
if (demo)
|
||||
FilledButton(
|
||||
key: const ValueKey('demo-confirmation'),
|
||||
onPressed: () => context.goNamed(
|
||||
AppRoutes.verifyEmail,
|
||||
queryParameters: {'token': 'demo-valid'},
|
||||
),
|
||||
child: Text(
|
||||
l.verificationDemoLink,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
OutlinedButton(
|
||||
key: const ValueKey('verification-resend'),
|
||||
onPressed: _send,
|
||||
child: Text(
|
||||
demo ? l.demoResend : l.verificationResend,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
if (!demo) ...[
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
l.verificationHint,
|
||||
style: theme.textTheme.bodyMedium,
|
||||
),
|
||||
],
|
||||
if (!verified && status != VerificationStatus.checking) ...[
|
||||
],
|
||||
if (!verified &&
|
||||
!sent &&
|
||||
!retryConfirmation &&
|
||||
status != VerificationStatus.checking) ...[
|
||||
const SizedBox(height: 28),
|
||||
Form(
|
||||
key: _form,
|
||||
child: TextFormField(
|
||||
key: const ValueKey('verification-email'),
|
||||
controller: _email,
|
||||
focusNode: _emailFocus,
|
||||
autocorrect: false,
|
||||
enabled: !state.isLoading,
|
||||
keyboardType: TextInputType.emailAddress,
|
||||
autofillHints: const [AutofillHints.email],
|
||||
@@ -227,46 +308,28 @@ class _VerificationPageState extends State<VerificationPage> {
|
||||
child: Text(
|
||||
state.isLoading
|
||||
? l.authWorking
|
||||
: status == VerificationStatus.sent
|
||||
? l.verificationResend
|
||||
: l.verificationRequest,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
],
|
||||
if (status == VerificationStatus.error &&
|
||||
!_lastWasSend &&
|
||||
widget.token != null) ...[
|
||||
if (retryConfirmation) ...[
|
||||
const SizedBox(height: 12),
|
||||
OutlinedButton(
|
||||
FilledButton(
|
||||
key: const ValueKey('verification-retry'),
|
||||
onPressed: () => _provider.confirm(widget.token!),
|
||||
child: Text(l.verificationRetry),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: _changeEmail,
|
||||
child: Text(l.verificationRequest),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 16),
|
||||
TextButton(
|
||||
onPressed: () => context.goNamed(AppRoutes.login),
|
||||
child: Text(l.verificationBack),
|
||||
),
|
||||
if (demo) ...[
|
||||
const SizedBox(height: 24),
|
||||
Text(
|
||||
l.verificationDemo,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: colors.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
if (status == VerificationStatus.sent)
|
||||
TextButton(
|
||||
key: const ValueKey('demo-confirmation'),
|
||||
onPressed: () => context.goNamed(
|
||||
AppRoutes.verifyEmail,
|
||||
queryParameters: {'token': 'demo-valid'},
|
||||
),
|
||||
child: Text(l.verificationDemoLink),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import 'package:sxchnge_analytics/sxchnge_analytics.dart';
|
||||
|
||||
import 'package:sxchnge_auth/sxchnge_auth.dart';
|
||||
|
||||
import 'package:sxchnge_app/main.dart';
|
||||
import 'package:sxchnge_app/routing/routes.dart';
|
||||
|
||||
Future<void> open(
|
||||
WidgetTester tester,
|
||||
String path,
|
||||
DummyAnalyticsService sink,
|
||||
) async {
|
||||
tester.platformDispatcher.defaultRouteNameTestValue = path;
|
||||
addTearDown(tester.platformDispatcher.clearDefaultRouteNameTestValue);
|
||||
await tester.pumpWidget(
|
||||
SxchngeApp(
|
||||
analyticsService: sink,
|
||||
signupService: const DummySignupService(delay: Duration.zero),
|
||||
verificationService: const DummyVerificationService(delay: Duration.zero),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
Future<void> tap(WidgetTester tester, Finder finder) async {
|
||||
await tester.ensureVisible(finder);
|
||||
await tester.tap(finder);
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
void main() {
|
||||
testWidgets(
|
||||
'recipient change removes sent claim, focuses editor and requires a new send',
|
||||
(tester) async {
|
||||
final sink = DummyAnalyticsService();
|
||||
await open(tester, '/verification-sent', sink);
|
||||
final email = find.byKey(const ValueKey('verification-email'));
|
||||
await tester.enterText(email, 'alex@example.com');
|
||||
await tap(tester, find.byKey(const ValueKey('verification-resend')));
|
||||
expect(email, findsNothing);
|
||||
expect(
|
||||
find.byKey(const ValueKey('verification-recipient')),
|
||||
findsOneWidget,
|
||||
);
|
||||
await tap(
|
||||
tester,
|
||||
find.byKey(const ValueKey('verification-change-email')),
|
||||
);
|
||||
expect(find.byKey(const ValueKey('verification-sent')), findsNothing);
|
||||
final editable = tester.widget<EditableText>(
|
||||
find.descendant(of: email, matching: find.byType(EditableText)),
|
||||
);
|
||||
expect(editable.focusNode.hasFocus, isTrue);
|
||||
await tester.enterText(email, 'different@example.com');
|
||||
expect(
|
||||
find.byKey(const ValueKey('verification-recipient')),
|
||||
findsNothing,
|
||||
);
|
||||
await tester.testTextInput.receiveAction(TextInputAction.send);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('different@example.com'), findsOneWidget);
|
||||
expect(
|
||||
sink.events
|
||||
.where(
|
||||
(e) =>
|
||||
e.step == JourneyStep.emailDelivery &&
|
||||
e.signal == JourneySignal.requested,
|
||||
)
|
||||
.length,
|
||||
2,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets(
|
||||
'confirmation error has primary retry and keyboard-accessible new-email recovery',
|
||||
(tester) async {
|
||||
final semantics = tester.ensureSemantics();
|
||||
final sink = DummyAnalyticsService();
|
||||
await open(tester, '/verify-email?token=demo-internal', sink);
|
||||
expect(
|
||||
tester.widget(find.byKey(const ValueKey('verification-retry'))),
|
||||
isA<FilledButton>(),
|
||||
);
|
||||
expect(find.byKey(const ValueKey('verification-email')), findsNothing);
|
||||
final node = tester.getSemantics(
|
||||
find.byKey(const ValueKey('verification-error')),
|
||||
);
|
||||
expect(node.getSemanticsData().flagsCollection.isHeader, isTrue);
|
||||
expect(node.getSemanticsData().flagsCollection.isLiveRegion, isTrue);
|
||||
await tap(tester, find.text('Send a new verification email'));
|
||||
await tester.enterText(
|
||||
find.byKey(const ValueKey('verification-email')),
|
||||
'alex@example.com',
|
||||
);
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.tab);
|
||||
await tester.pump();
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.enter);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byKey(const ValueKey('verification-sent')), findsOneWidget);
|
||||
expect(sink.events.map((e) => e.journeyId).toSet().length, 1);
|
||||
semantics.dispose();
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets(
|
||||
'signup to demo verification is correlated and never claims a business success',
|
||||
(tester) async {
|
||||
final sink = DummyAnalyticsService();
|
||||
await open(tester, '/', sink);
|
||||
await tap(tester, find.text('Sign up'));
|
||||
await tester.enterText(find.byKey(const ValueKey('name')), 'Alex');
|
||||
await tester.enterText(
|
||||
find.byKey(const ValueKey('email')),
|
||||
'alex@example.com',
|
||||
);
|
||||
await tester.enterText(
|
||||
find.byKey(const ValueKey('password')),
|
||||
'example-password',
|
||||
);
|
||||
await tester.enterText(
|
||||
find.byKey(const ValueKey('confirmation')),
|
||||
'example-password',
|
||||
);
|
||||
await tap(tester, find.byKey(const ValueKey('submit')));
|
||||
expect(find.text('Demo email prepared.'), findsOneWidget);
|
||||
expect(find.textContaining('We’ve sent you'), findsNothing);
|
||||
await tap(tester, find.byKey(const ValueKey('demo-confirmation')));
|
||||
expect(find.text('Demo verification\ncomplete.'), findsOneWidget);
|
||||
expect(sink.events.map((e) => e.journeyId).toSet().length, 1);
|
||||
expect(sink.events.where((e) => e.terminal).length, 1);
|
||||
expect(
|
||||
sink.events.where((e) => e.signal == JourneySignal.succeeded),
|
||||
isEmpty,
|
||||
);
|
||||
expect(sink.events.every((e) => e.simulated), isTrue);
|
||||
final payload = sink.events.map((e) => e.properties).toString();
|
||||
for (final secret in [
|
||||
'alex@example.com',
|
||||
'example-password',
|
||||
'Alex',
|
||||
'demo-valid',
|
||||
]) {
|
||||
expect(payload, isNot(contains(secret)));
|
||||
}
|
||||
final count = sink.events.length;
|
||||
await tester.pumpAndSettle();
|
||||
expect(sink.events.length, count);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets(
|
||||
'exit from a deep-linked failed confirmation records abandonment once',
|
||||
(tester) async {
|
||||
final sink = DummyAnalyticsService();
|
||||
await open(tester, '/verify-email?token=demo-expired', sink);
|
||||
await tap(tester, find.text('Back to login'));
|
||||
expect(sink.events.last.signal, JourneySignal.abandoned);
|
||||
expect(sink.events.last.exit, JourneyExit.navigation);
|
||||
expect(sink.events.where((e) => e.terminal).length, 1);
|
||||
final router = GoRouter.of(tester.element(find.byType(Scaffold)));
|
||||
router.goNamed(AppRoutes.login);
|
||||
await tester.pumpAndSettle();
|
||||
expect(sink.events.where((e) => e.terminal).length, 1);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets(
|
||||
'verification controls have accessible labels and mobile tap targets',
|
||||
(tester) async {
|
||||
final semantics = tester.ensureSemantics();
|
||||
tester.view.physicalSize = const Size(390, 844);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
await open(
|
||||
tester,
|
||||
'/verify-email?token=demo-internal',
|
||||
DummyAnalyticsService(),
|
||||
);
|
||||
await expectLater(tester, meetsGuideline(labeledTapTargetGuideline));
|
||||
await expectLater(tester, meetsGuideline(androidTapTargetGuideline));
|
||||
semantics.dispose();
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
.dart_tool/
|
||||
build/
|
||||
pubspec.lock
|
||||
@@ -0,0 +1,21 @@
|
||||
# SXCHNGE analytics
|
||||
|
||||
Shared pure-Dart analytics contract, semantic journey recorder and bounded
|
||||
in-memory dummy service. No PostHog SDK, network, persistence or logging.
|
||||
|
||||
```dart
|
||||
final analytics = DummyAnalyticsService();
|
||||
final recorder = JourneyRecorder(analytics);
|
||||
final operation = recorder.begin(JourneyKind.login, JourneyStep.login);
|
||||
recorder.finish(operation, simulated: true, terminal: true);
|
||||
final captured = analytics.events; // immutable snapshot
|
||||
recorder.dispose();
|
||||
```
|
||||
|
||||
Inject an `AnalyticsService` implementation to replace the sink. Consumers do not
|
||||
await analytics. See [the auth journey contract](../../../docs/ux/auth-journeys.md)
|
||||
for schema, metric definitions, simulation rules, privacy, retry/abandonment
|
||||
semantics, PostHog adapter guidance and measurement limitations.
|
||||
|
||||
Run `flutter test` and `flutter analyze` here. The production library imports
|
||||
only Dart SDK libraries and can be reused by web and mobile.
|
||||
@@ -0,0 +1,245 @@
|
||||
import 'dart:async';
|
||||
import 'dart:collection';
|
||||
import 'dart:math';
|
||||
|
||||
enum JourneyKind { login, signup, verification }
|
||||
|
||||
enum JourneyStep { login, signup, emailDelivery, emailConfirmation }
|
||||
|
||||
enum JourneySignal {
|
||||
started,
|
||||
requested,
|
||||
succeeded,
|
||||
simulated,
|
||||
failed,
|
||||
abandoned,
|
||||
}
|
||||
|
||||
enum JourneyFailure {
|
||||
invalidCredentials,
|
||||
emailInUse,
|
||||
unavailable,
|
||||
unexpected,
|
||||
expired,
|
||||
invalid,
|
||||
internal,
|
||||
}
|
||||
|
||||
enum JourneyExit { navigation, modeChange, inactivity, disposed }
|
||||
|
||||
String _snake(String value) =>
|
||||
value.replaceAllMapped(RegExp('[A-Z]'), (m) => '_${m[0]!.toLowerCase()}');
|
||||
String _id() {
|
||||
final random = Random.secure();
|
||||
return List.generate(
|
||||
16,
|
||||
(_) => random.nextInt(256).toRadixString(16).padLeft(2, '0'),
|
||||
).join();
|
||||
}
|
||||
|
||||
/// Only typed, allowlisted fields; never accepts form data, URLs or exceptions.
|
||||
class AnalyticsEvent {
|
||||
AnalyticsEvent({
|
||||
required this.journeyId,
|
||||
required this.kind,
|
||||
required this.signal,
|
||||
required this.sequence,
|
||||
required this.elapsedMs,
|
||||
this.step,
|
||||
this.operation,
|
||||
this.failure,
|
||||
this.exit,
|
||||
this.terminal = false,
|
||||
this.simulated = false,
|
||||
}) : eventId = _id(),
|
||||
occurredAt = DateTime.now().toUtc();
|
||||
final String eventId;
|
||||
final String journeyId;
|
||||
final JourneyKind kind;
|
||||
final JourneySignal signal;
|
||||
final JourneyStep? step;
|
||||
final JourneyFailure? failure;
|
||||
final JourneyExit? exit;
|
||||
final int sequence;
|
||||
final int? operation;
|
||||
final int elapsedMs;
|
||||
final bool terminal;
|
||||
final bool simulated;
|
||||
final DateTime occurredAt;
|
||||
String get name =>
|
||||
'auth_${step == null ? 'journey' : _snake(step!.name)}_${signal.name}';
|
||||
Map<String, Object> get properties => Map.unmodifiable({
|
||||
'schema_version': 1,
|
||||
'event_id': eventId,
|
||||
'journey_id': journeyId,
|
||||
'journey_kind': kind.name,
|
||||
'sequence': sequence,
|
||||
'elapsed_ms': elapsedMs,
|
||||
'occurred_at': occurredAt.toIso8601String(),
|
||||
'terminal': terminal,
|
||||
'simulated': simulated,
|
||||
if (step != null) 'step': _snake(step!.name),
|
||||
if (operation != null) 'operation': operation!,
|
||||
if (failure != null) 'failure': _snake(failure!.name),
|
||||
if (exit != null) 'exit': _snake(exit!.name),
|
||||
});
|
||||
}
|
||||
|
||||
abstract interface class AnalyticsService {
|
||||
Future<void> capture(AnalyticsEvent event);
|
||||
}
|
||||
|
||||
/// Bounded, process-local inspection only. No disk, logs, network or SDK.
|
||||
class DummyAnalyticsService implements AnalyticsService {
|
||||
DummyAnalyticsService({this.capacity = 500}) : assert(capacity > 0);
|
||||
final int capacity;
|
||||
final Queue<AnalyticsEvent> _events = Queue();
|
||||
List<AnalyticsEvent> get events => List.unmodifiable(_events);
|
||||
void clear() => _events.clear();
|
||||
@override
|
||||
Future<void> capture(AnalyticsEvent event) async {
|
||||
if (_events.any((item) => item.eventId == event.eventId)) return;
|
||||
if (_events.length == capacity) _events.removeFirst();
|
||||
_events.add(event);
|
||||
}
|
||||
}
|
||||
|
||||
class _Journey {
|
||||
_Journey(this.kind);
|
||||
final JourneyKind kind;
|
||||
final String id = _id();
|
||||
final Stopwatch time = Stopwatch()..start();
|
||||
int sequence = 0;
|
||||
int operations = 0;
|
||||
int pending = 0;
|
||||
bool closed = false;
|
||||
bool simulated = false;
|
||||
}
|
||||
|
||||
class JourneyOperation {
|
||||
JourneyOperation._(this._journey, this.step, this.number);
|
||||
final _Journey _journey;
|
||||
final JourneyStep step;
|
||||
final int number;
|
||||
bool _finished = false;
|
||||
}
|
||||
|
||||
/// One foreground auth journey. Retried operations share a journey, not outcomes.
|
||||
class JourneyRecorder {
|
||||
JourneyRecorder(
|
||||
this.service, {
|
||||
this.idleWindow = const Duration(minutes: 30),
|
||||
});
|
||||
final AnalyticsService service;
|
||||
final Duration idleWindow;
|
||||
_Journey? _active;
|
||||
Timer? _idle;
|
||||
bool _disposed = false;
|
||||
|
||||
JourneyOperation begin(
|
||||
JourneyKind kind,
|
||||
JourneyStep step, {
|
||||
bool onboarding = false,
|
||||
bool simulated = false,
|
||||
}) {
|
||||
var journey = _active;
|
||||
if (journey == null ||
|
||||
journey.closed ||
|
||||
(journey.kind != kind &&
|
||||
!(onboarding && journey.kind == JourneyKind.signup))) {
|
||||
abandon(JourneyExit.navigation);
|
||||
journey = _Journey(kind)..simulated = simulated;
|
||||
_active = journey;
|
||||
_emit(journey, JourneySignal.started);
|
||||
}
|
||||
journey.simulated |= simulated;
|
||||
_idle?.cancel();
|
||||
journey.pending++;
|
||||
final operation = JourneyOperation._(journey, step, ++journey.operations);
|
||||
_emit(journey, JourneySignal.requested, operation: operation);
|
||||
return operation;
|
||||
}
|
||||
|
||||
void finish(
|
||||
JourneyOperation operation, {
|
||||
bool simulated = false,
|
||||
JourneyFailure? failure,
|
||||
bool terminal = false,
|
||||
}) {
|
||||
final journey = operation._journey;
|
||||
if (_disposed || operation._finished || journey.closed) return;
|
||||
operation._finished = true;
|
||||
journey.pending--;
|
||||
journey.simulated |= simulated;
|
||||
final complete = failure == null && terminal;
|
||||
_emit(
|
||||
journey,
|
||||
failure != null
|
||||
? JourneySignal.failed
|
||||
: (simulated || (complete && journey.simulated))
|
||||
? JourneySignal.simulated
|
||||
: JourneySignal.succeeded,
|
||||
operation: operation,
|
||||
failure: failure,
|
||||
terminal: complete,
|
||||
simulated: simulated || (complete && journey.simulated),
|
||||
);
|
||||
if (complete) {
|
||||
journey.closed = true;
|
||||
_idle?.cancel();
|
||||
} else if (journey.pending == 0) {
|
||||
_idle?.cancel();
|
||||
_idle = Timer(idleWindow, () => abandon(JourneyExit.inactivity));
|
||||
}
|
||||
}
|
||||
|
||||
void abandon(JourneyExit reason) {
|
||||
_idle?.cancel();
|
||||
final journey = _active;
|
||||
if (_disposed || journey == null || journey.closed) return;
|
||||
_emit(
|
||||
journey,
|
||||
JourneySignal.abandoned,
|
||||
terminal: true,
|
||||
exit: reason,
|
||||
simulated: journey.simulated,
|
||||
);
|
||||
journey.closed = true;
|
||||
}
|
||||
|
||||
void _emit(
|
||||
_Journey journey,
|
||||
JourneySignal signal, {
|
||||
JourneyOperation? operation,
|
||||
JourneyFailure? failure,
|
||||
JourneyExit? exit,
|
||||
bool terminal = false,
|
||||
bool simulated = false,
|
||||
}) {
|
||||
if (_disposed) return;
|
||||
final event = AnalyticsEvent(
|
||||
journeyId: journey.id,
|
||||
kind: journey.kind,
|
||||
signal: signal,
|
||||
sequence: ++journey.sequence,
|
||||
elapsedMs: journey.time.elapsedMilliseconds,
|
||||
step: operation?.step,
|
||||
operation: operation?.number,
|
||||
failure: failure,
|
||||
exit: exit,
|
||||
terminal: terminal,
|
||||
simulated: simulated || journey.simulated,
|
||||
);
|
||||
// Adapters may throw synchronously, fail asynchronously or never finish.
|
||||
// None may delay or change the authentication operation.
|
||||
unawaited(
|
||||
Future<void>.sync(() => service.capture(event)).catchError((Object _) {}),
|
||||
);
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
abandon(JourneyExit.disposed);
|
||||
_disposed = true;
|
||||
_idle?.cancel();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
name: sxchnge_analytics
|
||||
description: Privacy-minimal journey analytics contracts and in-memory dummy service.
|
||||
publish_to: none
|
||||
version: 0.1.0
|
||||
environment:
|
||||
sdk: '>=3.13.0 <4.0.0'
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
sdk: flutter
|
||||
@@ -0,0 +1,116 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'package:sxchnge_analytics/sxchnge_analytics.dart';
|
||||
|
||||
class BrokenService implements AnalyticsService {
|
||||
BrokenService(this.mode);
|
||||
final int mode;
|
||||
@override
|
||||
Future<void> capture(AnalyticsEvent event) {
|
||||
if (mode == 0) throw StateError('offline');
|
||||
if (mode == 1) return Future.error(StateError('offline'));
|
||||
return Completer<void>().future;
|
||||
}
|
||||
}
|
||||
|
||||
void main() {
|
||||
test('signup through verification shares ID; retries and duplicates do not inflate outcomes', () {
|
||||
final sink = DummyAnalyticsService();
|
||||
final recorder = JourneyRecorder(sink);
|
||||
addTearDown(recorder.dispose);
|
||||
final signup = recorder.begin(JourneyKind.signup, JourneyStep.signup);
|
||||
recorder.finish(signup, simulated: true);
|
||||
final send = recorder.begin(
|
||||
JourneyKind.verification,
|
||||
JourneyStep.emailDelivery,
|
||||
onboarding: true,
|
||||
);
|
||||
recorder.finish(send, simulated: true);
|
||||
final failed = recorder.begin(
|
||||
JourneyKind.verification,
|
||||
JourneyStep.emailConfirmation,
|
||||
onboarding: true,
|
||||
);
|
||||
recorder.finish(failed, failure: JourneyFailure.expired);
|
||||
final retry = recorder.begin(
|
||||
JourneyKind.verification,
|
||||
JourneyStep.emailConfirmation,
|
||||
onboarding: true,
|
||||
);
|
||||
recorder.finish(retry, simulated: true, terminal: true);
|
||||
recorder.finish(retry, simulated: true, terminal: true);
|
||||
expect(sink.events.map((e) => e.journeyId).toSet().length, 1);
|
||||
expect(sink.events.where((e) => e.terminal).length, 1);
|
||||
expect(sink.events.last.signal, JourneySignal.simulated);
|
||||
expect(sink.events.last.operation, 4);
|
||||
expect(
|
||||
sink.events.map((e) => e.sequence),
|
||||
orderedEquals(List.generate(9, (i) => i + 1)),
|
||||
);
|
||||
expect(
|
||||
sink.events.any((e) => e.properties.toString().contains('email_address')),
|
||||
isFalse,
|
||||
);
|
||||
});
|
||||
test(
|
||||
'explicit exit records once and suppresses late operation completion',
|
||||
() {
|
||||
final sink = DummyAnalyticsService();
|
||||
final recorder = JourneyRecorder(sink);
|
||||
addTearDown(recorder.dispose);
|
||||
final op = recorder.begin(JourneyKind.login, JourneyStep.login);
|
||||
recorder.abandon(JourneyExit.modeChange);
|
||||
recorder.abandon(JourneyExit.navigation);
|
||||
recorder.finish(op, terminal: true);
|
||||
expect(sink.events.last.signal, JourneySignal.abandoned);
|
||||
expect(sink.events.where((e) => e.terminal).length, 1);
|
||||
final newOp = recorder.begin(JourneyKind.login, JourneyStep.login);
|
||||
recorder.finish(newOp, terminal: true);
|
||||
expect(sink.events.map((e) => e.journeyId).toSet().length, 2);
|
||||
},
|
||||
);
|
||||
test(
|
||||
'inactivity waits for pending operations and starts after settled failure',
|
||||
() async {
|
||||
final sink = DummyAnalyticsService();
|
||||
final recorder = JourneyRecorder(
|
||||
sink,
|
||||
idleWindow: const Duration(milliseconds: 10),
|
||||
);
|
||||
addTearDown(recorder.dispose);
|
||||
final op = recorder.begin(JourneyKind.login, JourneyStep.login);
|
||||
await Future<void>.delayed(const Duration(milliseconds: 25));
|
||||
expect(sink.events.any((e) => e.terminal), isFalse);
|
||||
recorder.finish(op, failure: JourneyFailure.unavailable);
|
||||
await Future<void>.delayed(const Duration(milliseconds: 25));
|
||||
expect(sink.events.last.exit, JourneyExit.inactivity);
|
||||
},
|
||||
);
|
||||
for (final mode in [0, 1, 2]) {
|
||||
test('analytics failure mode $mode never blocks outcomes', () async {
|
||||
final recorder = JourneyRecorder(BrokenService(mode));
|
||||
final op = recorder.begin(JourneyKind.login, JourneyStep.login);
|
||||
recorder.finish(op, terminal: true);
|
||||
recorder.dispose();
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
});
|
||||
}
|
||||
test(
|
||||
'dummy is bounded, immutable and deduplicates repeated event IDs',
|
||||
() async {
|
||||
final sink = DummyAnalyticsService(capacity: 2);
|
||||
final recorder = JourneyRecorder(sink);
|
||||
addTearDown(recorder.dispose);
|
||||
final op = recorder.begin(JourneyKind.login, JourneyStep.login);
|
||||
final event = sink.events.last;
|
||||
await sink.capture(event);
|
||||
expect(sink.events.length, 2);
|
||||
recorder.finish(op, terminal: true);
|
||||
expect(sink.events.length, 2);
|
||||
expect(() => sink.events.clear(), throwsUnsupportedError);
|
||||
expect(() => event.properties['password'] = 'no', throwsUnsupportedError);
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -61,3 +61,8 @@ When a supported sxchnge BFF contract exists, implement transport DTOs and mappe
|
||||
`VerificationService` exposes `send(email, locale)` and `confirm(token)` as named-argument methods. Inject an implementation into `VerificationProvider`, shared by web and mobile. The provider exposes immutable `VerificationState` (idle/sending/sent/checking/verified/expired/invalid/error), suppresses concurrent duplicate operations, and ignores completions after reset or disposal. Typed `VerificationException` maps confirmation failures; unknown exceptions become internal errors without exposing exception text. Sending failures always become the general error state.
|
||||
|
||||
`DummyVerificationService` supports `demo-valid`, `demo-expired`, and `demo-internal` confirmation tokens; other tokens are invalid. Configure `delay` and `sendFailure` for tests. Results are explicitly simulated. It performs no network, storage, mail delivery, or account mutation. A production adapter must implement backend validation, delivery, rate limits, and safe token handling; this client provider is not an authorization boundary.
|
||||
|
||||
`InstrumentedAuthServices` decorates the three auth service interfaces with a
|
||||
shared `JourneyRecorder` from `sxchnge_analytics`. It preserves results/exceptions
|
||||
for providers while emitting only typed semantic outcomes. `SimulationInfo`
|
||||
allows decorators and UI to identify preview services before the first result.
|
||||
@@ -17,9 +17,7 @@ class VerificationProvider extends ChangeNotifier {
|
||||
VerificationStatus.sent,
|
||||
);
|
||||
Future<void> confirm(String token) => _run(
|
||||
() => token.trim().isEmpty
|
||||
? Future.error(const VerificationException(VerificationError.invalid))
|
||||
: service.confirm(token: token),
|
||||
() => service.confirm(token: token),
|
||||
VerificationStatus.checking,
|
||||
VerificationStatus.verified,
|
||||
);
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import '../models/auth.dart';
|
||||
import 'contracts.dart';
|
||||
import 'verification.dart';
|
||||
|
||||
/// Deterministic local simulation. No network, storage, credentials or sessions
|
||||
/// are retained. Configure failures explicitly rather than using magic emails.
|
||||
class DummyLoginService implements LoginService {
|
||||
class DummyLoginService implements LoginService, SimulationInfo {
|
||||
@override
|
||||
bool get isSimulated => true;
|
||||
const DummyLoginService({
|
||||
this.delay = const Duration(milliseconds: 600),
|
||||
this.failure,
|
||||
@@ -19,7 +22,9 @@ class DummyLoginService implements LoginService {
|
||||
}
|
||||
}
|
||||
|
||||
class DummySignupService implements SignupService {
|
||||
class DummySignupService implements SignupService, SimulationInfo {
|
||||
@override
|
||||
bool get isSimulated => true;
|
||||
const DummySignupService({
|
||||
this.delay = const Duration(milliseconds: 600),
|
||||
this.failure,
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
import 'package:sxchnge_analytics/sxchnge_analytics.dart';
|
||||
|
||||
import '../models/auth.dart';
|
||||
import '../models/verification.dart';
|
||||
import 'contracts.dart';
|
||||
import 'verification.dart';
|
||||
|
||||
/// Decorates domain services, so widget rebuilds never produce analytics events.
|
||||
class InstrumentedAuthServices
|
||||
implements
|
||||
LoginService,
|
||||
SignupService,
|
||||
VerificationService,
|
||||
SimulationInfo {
|
||||
InstrumentedAuthServices({
|
||||
required this.recorder,
|
||||
required this.loginService,
|
||||
required this.signupService,
|
||||
required this.verificationService,
|
||||
});
|
||||
final JourneyRecorder recorder;
|
||||
final LoginService loginService;
|
||||
final SignupService signupService;
|
||||
final VerificationService verificationService;
|
||||
@override
|
||||
bool get isSimulated =>
|
||||
verificationService is SimulationInfo &&
|
||||
(verificationService as SimulationInfo).isSimulated;
|
||||
|
||||
Future<T> _run<T>(
|
||||
JourneyKind kind,
|
||||
JourneyStep step,
|
||||
Future<T> Function() action,
|
||||
bool Function(T) simulated, {
|
||||
bool terminal = false,
|
||||
bool onboarding = false,
|
||||
bool simulation = false,
|
||||
}) async {
|
||||
final operation = recorder.begin(
|
||||
kind,
|
||||
step,
|
||||
onboarding: onboarding,
|
||||
simulated: simulation,
|
||||
);
|
||||
try {
|
||||
final result = await action();
|
||||
recorder.finish(
|
||||
operation,
|
||||
simulated: simulated(result),
|
||||
terminal: terminal,
|
||||
);
|
||||
return result;
|
||||
} catch (error) {
|
||||
final failure = switch (error) {
|
||||
AuthException(code: final code) => switch (code) {
|
||||
AuthError.invalidCredentials => JourneyFailure.invalidCredentials,
|
||||
AuthError.emailInUse => JourneyFailure.emailInUse,
|
||||
AuthError.unavailable => JourneyFailure.unavailable,
|
||||
AuthError.unexpected => JourneyFailure.unexpected,
|
||||
},
|
||||
VerificationException(code: final code) => switch (code) {
|
||||
VerificationError.expired => JourneyFailure.expired,
|
||||
VerificationError.invalid => JourneyFailure.invalid,
|
||||
VerificationError.internal => JourneyFailure.internal,
|
||||
},
|
||||
_ => JourneyFailure.unexpected,
|
||||
};
|
||||
recorder.finish(operation, failure: failure);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
bool _simulation(Object service) =>
|
||||
service is SimulationInfo && service.isSimulated;
|
||||
|
||||
@override
|
||||
Future<AuthResult> login(LoginRequest request) => _run(
|
||||
JourneyKind.login,
|
||||
JourneyStep.login,
|
||||
() => loginService.login(request),
|
||||
(r) => r.isSimulated,
|
||||
terminal: true,
|
||||
simulation: _simulation(loginService),
|
||||
);
|
||||
@override
|
||||
Future<AuthResult> signup(SignupRequest request) => _run(
|
||||
JourneyKind.signup,
|
||||
JourneyStep.signup,
|
||||
() => signupService.signup(request),
|
||||
(r) => r.isSimulated,
|
||||
simulation: _simulation(signupService),
|
||||
);
|
||||
@override
|
||||
Future<VerificationResult> send({
|
||||
required String email,
|
||||
required String locale,
|
||||
}) => _run(
|
||||
JourneyKind.verification,
|
||||
JourneyStep.emailDelivery,
|
||||
() => verificationService.send(email: email, locale: locale),
|
||||
(r) => r.isSimulated,
|
||||
onboarding: true,
|
||||
simulation: isSimulated,
|
||||
);
|
||||
@override
|
||||
Future<VerificationResult> confirm({required String token}) => _run(
|
||||
JourneyKind.verification,
|
||||
JourneyStep.emailConfirmation,
|
||||
() => token.trim().isEmpty
|
||||
? Future.error(const VerificationException(VerificationError.invalid))
|
||||
: verificationService.confirm(token: token),
|
||||
(r) => r.isSimulated,
|
||||
terminal: true,
|
||||
onboarding: true,
|
||||
simulation: isSimulated,
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,9 @@
|
||||
import '../models/verification.dart';
|
||||
|
||||
abstract interface class SimulationInfo {
|
||||
bool get isSimulated;
|
||||
}
|
||||
|
||||
abstract interface class VerificationService {
|
||||
Future<VerificationResult> send({
|
||||
required String email,
|
||||
@@ -9,11 +13,13 @@ abstract interface class VerificationService {
|
||||
}
|
||||
|
||||
/// Explicit preview tokens only. Never use this implementation in production.
|
||||
class DummyVerificationService implements VerificationService {
|
||||
class DummyVerificationService implements VerificationService, SimulationInfo {
|
||||
const DummyVerificationService({
|
||||
this.delay = const Duration(milliseconds: 600),
|
||||
this.sendFailure,
|
||||
});
|
||||
@override
|
||||
bool get isSimulated => true;
|
||||
final Duration delay;
|
||||
final VerificationError? sendFailure;
|
||||
|
||||
|
||||
@@ -10,3 +10,5 @@ export 'src/services/dummy.dart';
|
||||
export 'src/models/verification.dart';
|
||||
export 'src/providers/verification.dart';
|
||||
export 'src/services/verification.dart';
|
||||
|
||||
export 'src/services/instrumented.dart';
|
||||
@@ -6,6 +6,8 @@ environment:
|
||||
sdk: '>=3.13.0 <4.0.0'
|
||||
flutter: '>=3.47.0'
|
||||
dependencies:
|
||||
sxchnge_analytics:
|
||||
path: ../sxchnge_analytics
|
||||
flutter:
|
||||
sdk: flutter
|
||||
dev_dependencies:
|
||||
|
||||
Reference in new issue
Block a user