Fix onboarding UX and prepare replaceable journey analytics #13
No files matched your search
@@ -1,6 +1,7 @@
|
|||||||
.PHONY: test-frontend
|
.PHONY: test-frontend
|
||||||
|
|
||||||
test-frontend:
|
test-frontend:
|
||||||
|
cd frontend/packages/sxchnge_analytics && flutter test
|
||||||
cd frontend/packages/sxchnge_auth && flutter test
|
cd frontend/packages/sxchnge_auth && flutter test
|
||||||
cd frontend/packages/sxchnge_theme && flutter test
|
cd frontend/packages/sxchnge_theme && flutter test
|
||||||
cd frontend/packages/sxchnge_theme/example && flutter test
|
cd frontend/packages/sxchnge_theme/example && flutter test
|
||||||
|
|||||||
@@ -106,3 +106,14 @@ Delivery validation passed: 56 frontend tests; formatting and all four Flutter
|
|||||||
analyses; 3 website tests, Astro check (zero errors/warnings), and website build.
|
analyses; 3 website tests, Astro check (zero errors/warnings), and website build.
|
||||||
The app web build and browser checks passed during the immediately preceding
|
The app web build and browser checks passed during the immediately preceding
|
||||||
routing task; delivery changed documentation and Git organization only.
|
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.
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
# Authentication UX and analytics contract
|
||||||
|
|
||||||
|
Task: [#12](https://git.sendico.io/tech/sxchnge/issues/12). Applies to login,
|
||||||
|
signup through email verification, and standalone confirmation links.
|
||||||
|
|
||||||
|
## User outcomes and interface
|
||||||
|
|
||||||
|
Signup leads to email delivery, then confirmation. Sent status shows a fixed
|
||||||
|
recipient; Change email explicitly leaves that state and focuses the editor.
|
||||||
|
A temporary confirmation failure prioritizes Retry verification, with requesting
|
||||||
|
a replacement email secondary. Expired/invalid links offer replacement delivery.
|
||||||
|
Demo labels sit beside the heading, and demo outcomes explicitly state that no
|
||||||
|
email was delivered or account verified. The demo confirmation link is the
|
||||||
|
primary next step on the simulated sent screen. EN/RU use the installed theme.
|
||||||
|
|
||||||
|
## Measurement scope and attempts
|
||||||
|
|
||||||
|
Owner/consumer: the sxchnge product team, diagnosing onboarding completion and
|
||||||
|
choosing between fixes to registration, delivery and confirmation recovery.
|
||||||
|
|
||||||
|
A journey starts at the first **locally valid service request**. This intentionally
|
||||||
|
measures submitted attempts, not page views or field editing; pre-submit form
|
||||||
|
abandonment and validation friction are outside this denominator. No generic
|
||||||
|
click/field events are collected.
|
||||||
|
|
||||||
|
- `login`: one requested login through its service result.
|
||||||
|
- `signup`: signup acceptance, email delivery requests, then email confirmation.
|
||||||
|
- `verification`: confirmation/replacement delivery when there is no active signup.
|
||||||
|
|
||||||
|
Retries reuse the journey ID and get a new operation number. Signup success and
|
||||||
|
email delivery are intermediate states; confirmation success is the onboarding
|
||||||
|
terminal outcome. Login success is terminal. Failed operations remain recoverable.
|
||||||
|
Every operation has one completion, and every journey at most one terminal event.
|
||||||
|
Changing auth mode, returning from verification to login, changing a confirmation
|
||||||
|
token, or disposing the app ends the open attempt explicitly. A new operation
|
||||||
|
after a terminal outcome starts a new journey.
|
||||||
|
|
||||||
|
The inactivity window is **30 minutes after the last settled nonterminal service
|
||||||
|
result**, reset by a new request. It is suspended while a service operation is
|
||||||
|
pending: missing completion is not assumed to be abandonment. An explicit exit
|
||||||
|
can end an attempt while work is pending; it does not cancel backend work. Late
|
||||||
|
results cannot reopen that attempt or emit success after its abandonment.
|
||||||
|
|
||||||
|
IDs are random, scoped to this app instance and never derived from identity or
|
||||||
|
link tokens. Same-instance signup → demo link is correlated. A real email opened
|
||||||
|
in another tab/device or after reload starts a standalone verification journey.
|
||||||
|
Cross-device signup conversion needs a future backend-issued, non-sensitive
|
||||||
|
correlation contract; client code must never use email or the token as that ID.
|
||||||
|
Tab termination is not reliably observable. Missing exits remain **unknown**,
|
||||||
|
not inferred abandonment. Background timer throttling can delay idle detection.
|
||||||
|
|
||||||
|
## Event schema (version 1)
|
||||||
|
|
||||||
|
| Event | Emission condition | Product decision supported |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `auth_journey_started` | First valid request for a new attempt | Submitted-attempt denominator |
|
||||||
|
| `auth_{step}_requested` | A domain service operation starts | Locate the last meaningful step and retries |
|
||||||
|
| `auth_{step}_succeeded` | Service confirms a non-simulated result | Step conversion and outcome duration |
|
||||||
|
| `auth_{step}_simulated` | Dummy result, or terminal outcome of a mixed simulated journey | Validate the preview funnel without reporting business conversion |
|
||||||
|
| `auth_{step}_failed` | Service operation fails | Failure rates by safe error category and recovery |
|
||||||
|
| `auth_journey_abandoned` | Explicit exit or settled inactivity timeout | Diagnose known abandonment/drop-off |
|
||||||
|
|
||||||
|
`step` is `login`, `signup`, `email_delivery`, or `email_confirmation`.
|
||||||
|
Properties: schema_version, event_id, journey_id, journey_kind, sequence,
|
||||||
|
operation (per-journey counter when applicable), occurred_at (UTC), elapsed_ms
|
||||||
|
(monotonic journey duration), terminal, simulated, and optional typed failure/exit.
|
||||||
|
Every event in a known dummy journey is marked simulated. No email, password,
|
||||||
|
name, user ID, confirmation token, URL, raw exception, or form contents is captured.
|
||||||
|
|
||||||
|
## Metrics
|
||||||
|
|
||||||
|
Group by journey_kind and journey_id; deduplicate delivery by event_id, ordering
|
||||||
|
by sequence. Use **closed attempts** (terminal outcome or explicit abandonment)
|
||||||
|
for settled cohort rates; report open/unknown counts separately. For a fixed
|
||||||
|
reporting cohort, include starts in that interval and allow their defined
|
||||||
|
inactivity window to settle. Report pending operations separately even after it.
|
||||||
|
|
||||||
|
- Completion: distinct successful terminal journey IDs / closed journey IDs.
|
||||||
|
- Simulated completion: same calculation using simulated terminals, reported
|
||||||
|
separately. Never mix these into actual account/session conversion.
|
||||||
|
- Failure rate: distinct failed operations / requested operations; also report
|
||||||
|
journeys with any failure / started journeys.
|
||||||
|
- Recovery: closed journeys completing after a failed operation / closed
|
||||||
|
journeys with a failed operation.
|
||||||
|
- Known abandonment: abandoned journey IDs / closed journey IDs, by exit reason.
|
||||||
|
- Step drop-off: closed journeys reaching a step but not the next / closed
|
||||||
|
journeys reaching that step (signup acceptance → delivery → confirmation).
|
||||||
|
- Duration: terminal elapsed_ms; step latency from matching requested/result
|
||||||
|
operation timestamps. Report distributions separately for retries and failures.
|
||||||
|
|
||||||
|
Current service results are client evidence only. Future production adapters must
|
||||||
|
return success only after server confirmation; delivery API acceptance still
|
||||||
|
isn't proof that the email reached an inbox. That remains a documented proxy.
|
||||||
|
|
||||||
|
## Dummy service and PostHog replacement
|
||||||
|
|
||||||
|
`sxchnge_analytics` is a platform-independent shared package. `AnalyticsService`
|
||||||
|
accepts typed `AnalyticsEvent`; `DummyAnalyticsService` retains the latest 500
|
||||||
|
unique events in memory for tests/debugging. It exposes an immutable event list
|
||||||
|
and clear(), writes no logs/files, installs no SDK, and makes no network requests.
|
||||||
|
It is diagnostic storage, not a durable metrics backend; older events are evicted.
|
||||||
|
|
||||||
|
Inject the service through `SxchngeApp(analyticsService: service)`. Auth services
|
||||||
|
are decorated by `InstrumentedAuthServices`, so rebuilds, locale changes and
|
||||||
|
layout changes cannot generate events. Analytics exceptions are swallowed at the
|
||||||
|
recorder boundary and analytics futures are never awaited by auth operations.
|
||||||
|
Adapters must do lightweight capture/enqueue work; synchronous CPU blocking in an
|
||||||
|
adapter cannot be isolated by a Dart Future.
|
||||||
|
|
||||||
|
A future PostHog adapter should implement capture using event.name and the
|
||||||
|
allowlisted event.properties. Official API reference:
|
||||||
|
https://pub.dev/documentation/posthog_flutter/latest/posthog_flutter/Posthog-class.html
|
||||||
|
|
||||||
|
Preserve event_id and sequence for deduplication/reconstruction. Configure consent,
|
||||||
|
retention, region and anonymous identity before enabling collection. Do not enable
|
||||||
|
autocapture, session replay, automatic URL capture or user identification as a
|
||||||
|
side effect of this adapter: confirmation URLs and authentication forms contain
|
||||||
|
sensitive data. No PostHog key or network integration is included in this task.
|
||||||
|
Retries/buffering in a future adapter must preserve the original event_id and
|
||||||
|
occurred_at, remain bounded, and never delay authentication.
|
||||||
|
|
||||||
|
## UX review and verification
|
||||||
|
|
||||||
|
- Purpose/primary action: explicit demo outcomes and primary confirmation retry.
|
||||||
|
- Context/recovery: confirmed recipient separate from editor, focus restored by
|
||||||
|
Change email, no sent claim while editing; service failure retains input.
|
||||||
|
- States: initial/loading/sent/verified/expired/invalid/internal error remain
|
||||||
|
available. Restricted/offline backend behavior is outside this dummy service.
|
||||||
|
- Responsiveness: EN/RU and 200% text coverage retained; focused tests cover
|
||||||
|
keyboard submission, semantic heading/live region and mobile target labels/sizes.
|
||||||
|
- Measurement: tests cover correlated success, simulated outcomes, failures,
|
||||||
|
retries, duplicate completion/delivery, explicit exit, pending operations,
|
||||||
|
inactivity, and throwing/failing/non-completing analytics adapters.
|
||||||
|
- Privacy: payload tests exclude supplied form values and tokens; only typed
|
||||||
|
allowlisted properties are exposed. Dummy buffer is bounded and read-only.
|
||||||
|
- Remaining limits: no real PostHog delivery or cross-device correlation is
|
||||||
|
claimed. Automated semantics checks do not replace assistive-technology testing
|
||||||
|
on supported devices; a native VoiceOver/TalkBack audit remains a release check.
|
||||||
|
|
||||||
|
### Recorded validation
|
||||||
|
|
||||||
|
The automated suite passes 68 tests: 7 analytics, 20 auth, 3 theme, 3 theme
|
||||||
|
consumer, and 35 app tests. Formatting and analysis pass for the three affected
|
||||||
|
packages; the web build passes. Browser walkthrough at 390px covers internal-error
|
||||||
|
hierarchy, secondary email recovery, actual Tab/Enter submission, and the demo
|
||||||
|
sent screen. The heading is exposed as a live accessibility heading. Recipient
|
||||||
|
text uses a normal text node to remain exposed to assistive technology.
|
||||||
@@ -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.
|
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`.
|
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:provider/provider.dart';
|
||||||
|
|
||||||
|
import 'package:sxchnge_analytics/sxchnge_analytics.dart';
|
||||||
|
|
||||||
import 'package:sxchnge_auth/sxchnge_auth.dart';
|
import 'package:sxchnge_auth/sxchnge_auth.dart';
|
||||||
|
|
||||||
import '../routing/routes.dart';
|
import '../routing/routes.dart';
|
||||||
@@ -58,6 +60,7 @@ class _AuthFormState extends State<AuthForm> {
|
|||||||
|
|
||||||
void _switch(bool signUp) {
|
void _switch(bool signUp) {
|
||||||
if (signUp == _signUp) return;
|
if (signUp == _signUp) return;
|
||||||
|
context.read<JourneyRecorder>().abandon(JourneyExit.modeChange);
|
||||||
context.read<LoginProvider>().reset();
|
context.read<LoginProvider>().reset();
|
||||||
context.read<SignupProvider>().reset();
|
context.read<SignupProvider>().reset();
|
||||||
_form.currentState?.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),
|
const SizedBox(height: 24),
|
||||||
Text(
|
Text(
|
||||||
_signUp ? l.signupHeading : l.loginHeading,
|
_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.
|
/// No description provided for @verificationRetry.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
/// **'Try again'**
|
/// **'Retry verification'**
|
||||||
String get verificationRetry;
|
String get verificationRetry;
|
||||||
|
|
||||||
/// No description provided for @verificationBack.
|
/// No description provided for @verificationBack.
|
||||||
@@ -493,6 +493,48 @@ abstract class AppLocalizations {
|
|||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
/// **'CONFIRM YOUR EMAIL'**
|
/// **'CONFIRM YOUR EMAIL'**
|
||||||
String get verificationStep;
|
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
|
class _AppLocalizationsDelegate
|
||||||
|
|||||||
@@ -201,7 +201,7 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
String get verificationRequest => 'Send a new verification email';
|
String get verificationRequest => 'Send a new verification email';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get verificationRetry => 'Try again';
|
String get verificationRetry => 'Retry verification';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get verificationBack => 'Back to login';
|
String get verificationBack => 'Back to login';
|
||||||
@@ -219,4 +219,27 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get verificationStep => 'CONFIRM YOUR EMAIL';
|
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 => 'Отправить новое письмо';
|
String get verificationRequest => 'Отправить новое письмо';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get verificationRetry => 'Попробовать ещё раз';
|
String get verificationRetry => 'Повторить подтверждение';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get verificationBack => 'Вернуться ко входу';
|
String get verificationBack => 'Вернуться ко входу';
|
||||||
@@ -220,4 +220,27 @@ class AppLocalizationsRu extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get verificationStep => 'ПОДТВЕРДИТЕ ПОЧТУ';
|
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.",
|
"verificationInternalBody": "We couldn’t complete your request because of an internal error. Please try again.",
|
||||||
"verificationResend": "Resend verification email",
|
"verificationResend": "Resend verification email",
|
||||||
"verificationRequest": "Send a new verification email",
|
"verificationRequest": "Send a new verification email",
|
||||||
"verificationRetry": "Try again",
|
"verificationRetry": "Retry verification",
|
||||||
"verificationBack": "Back to login",
|
"verificationBack": "Back to login",
|
||||||
"verificationHint": "No email? Check your spam folder or request another below.",
|
"verificationHint": "No email? Check your spam folder or request another below.",
|
||||||
"verificationDemo": "DEMO · No real email is sent and no account is verified.",
|
"verificationDemo": "DEMO · No real email is sent and no account is verified.",
|
||||||
"verificationDemoLink": "Open demo confirmation link",
|
"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": "Не удалось выполнить запрос из-за внутренней ошибки. Попробуйте ещё раз.",
|
"verificationInternalBody": "Не удалось выполнить запрос из-за внутренней ошибки. Попробуйте ещё раз.",
|
||||||
"verificationResend": "Отправить письмо ещё раз",
|
"verificationResend": "Отправить письмо ещё раз",
|
||||||
"verificationRequest": "Отправить новое письмо",
|
"verificationRequest": "Отправить новое письмо",
|
||||||
"verificationRetry": "Попробовать ещё раз",
|
"verificationRetry": "Повторить подтверждение",
|
||||||
"verificationBack": "Вернуться ко входу",
|
"verificationBack": "Вернуться ко входу",
|
||||||
"verificationHint": "Нет письма? Проверьте папку «Спам» или запросите новое ниже.",
|
"verificationHint": "Нет письма? Проверьте папку «Спам» или запросите новое ниже.",
|
||||||
"verificationDemo": "ДЕМО · Письмо не отправляется, реальный аккаунт не подтверждается.",
|
"verificationDemo": "ДЕМО · Письмо не отправляется, реальный аккаунт не подтверждается.",
|
||||||
"verificationDemoLink": "Открыть демоссылку подтверждения",
|
"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:provider/provider.dart';
|
||||||
|
|
||||||
|
import 'package:sxchnge_analytics/sxchnge_analytics.dart';
|
||||||
|
|
||||||
import 'package:sxchnge_auth/sxchnge_auth.dart';
|
import 'package:sxchnge_auth/sxchnge_auth.dart';
|
||||||
|
|
||||||
import 'package:sxchnge_theme/sxchnge_theme.dart';
|
import 'package:sxchnge_theme/sxchnge_theme.dart';
|
||||||
@@ -21,11 +23,13 @@ class SxchngeApp extends StatefulWidget {
|
|||||||
const SxchngeApp({
|
const SxchngeApp({
|
||||||
super.key,
|
super.key,
|
||||||
this.localeProvider,
|
this.localeProvider,
|
||||||
|
this.analyticsService,
|
||||||
this.loginService = const DummyLoginService(),
|
this.loginService = const DummyLoginService(),
|
||||||
this.signupService = const DummySignupService(),
|
this.signupService = const DummySignupService(),
|
||||||
this.verificationService = const DummyVerificationService(),
|
this.verificationService = const DummyVerificationService(),
|
||||||
});
|
});
|
||||||
final LocaleProvider? localeProvider;
|
final LocaleProvider? localeProvider;
|
||||||
|
final AnalyticsService? analyticsService;
|
||||||
final LoginService loginService;
|
final LoginService loginService;
|
||||||
final SignupService signupService;
|
final SignupService signupService;
|
||||||
final VerificationService verificationService;
|
final VerificationService verificationService;
|
||||||
@@ -37,11 +41,38 @@ class SxchngeApp extends StatefulWidget {
|
|||||||
class _SxchngeAppState extends State<SxchngeApp> {
|
class _SxchngeAppState extends State<SxchngeApp> {
|
||||||
late final _locale = widget.localeProvider ?? LocaleProvider();
|
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
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
|
_router.routeInformationProvider.removeListener(_routeChanged);
|
||||||
_router.dispose();
|
_router.dispose();
|
||||||
|
_journeys.dispose();
|
||||||
_locale.dispose();
|
_locale.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
@@ -49,12 +80,11 @@ class _SxchngeAppState extends State<SxchngeApp> {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) => MultiProvider(
|
Widget build(BuildContext context) => MultiProvider(
|
||||||
providers: [
|
providers: [
|
||||||
|
Provider<JourneyRecorder>.value(value: _journeys),
|
||||||
ChangeNotifierProvider.value(value: _locale),
|
ChangeNotifierProvider.value(value: _locale),
|
||||||
Provider<VerificationService>.value(value: widget.verificationService),
|
Provider<VerificationService>.value(value: _services),
|
||||||
ChangeNotifierProvider(create: (_) => LoginProvider(widget.loginService)),
|
ChangeNotifierProvider(create: (_) => LoginProvider(_services)),
|
||||||
ChangeNotifierProvider(
|
ChangeNotifierProvider(create: (_) => SignupProvider(_services)),
|
||||||
create: (_) => SignupProvider(widget.signupService),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
child: Consumer<LocaleProvider>(
|
child: Consumer<LocaleProvider>(
|
||||||
builder: (context, locale, _) => MaterialApp.router(
|
builder: (context, locale, _) => MaterialApp.router(
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import 'package:go_router/go_router.dart';
|
|||||||
|
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
|
import 'package:sxchnge_analytics/sxchnge_analytics.dart';
|
||||||
|
|
||||||
import 'package:sxchnge_auth/sxchnge_auth.dart';
|
import 'package:sxchnge_auth/sxchnge_auth.dart';
|
||||||
|
|
||||||
import '../auth/brand_panel.dart';
|
import '../auth/brand_panel.dart';
|
||||||
@@ -28,6 +30,8 @@ class _VerificationPageState extends State<VerificationPage> {
|
|||||||
late final _provider = VerificationProvider(
|
late final _provider = VerificationProvider(
|
||||||
context.read<VerificationService>(),
|
context.read<VerificationService>(),
|
||||||
);
|
);
|
||||||
|
final _emailFocus = FocusNode();
|
||||||
|
String? _recipient;
|
||||||
bool _initialized = false;
|
bool _initialized = false;
|
||||||
bool _lastWasSend = false;
|
bool _lastWasSend = false;
|
||||||
bool _validated = false;
|
bool _validated = false;
|
||||||
@@ -56,6 +60,7 @@ class _VerificationPageState extends State<VerificationPage> {
|
|||||||
void didUpdateWidget(covariant VerificationPage oldWidget) {
|
void didUpdateWidget(covariant VerificationPage oldWidget) {
|
||||||
super.didUpdateWidget(oldWidget);
|
super.didUpdateWidget(oldWidget);
|
||||||
if (oldWidget.token == widget.token) return;
|
if (oldWidget.token == widget.token) return;
|
||||||
|
context.read<JourneyRecorder>().abandon(JourneyExit.navigation);
|
||||||
_provider.reset();
|
_provider.reset();
|
||||||
_lastWasSend = false;
|
_lastWasSend = false;
|
||||||
_validated = false;
|
_validated = false;
|
||||||
@@ -68,7 +73,12 @@ class _VerificationPageState extends State<VerificationPage> {
|
|||||||
void _send() {
|
void _send() {
|
||||||
if (_provider.state.isLoading) return;
|
if (_provider.state.isLoading) return;
|
||||||
_validated = true;
|
_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;
|
_lastWasSend = true;
|
||||||
_provider.send(
|
_provider.send(
|
||||||
email: _email.text,
|
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
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_provider.dispose();
|
_provider.dispose();
|
||||||
_email.dispose();
|
_email.dispose();
|
||||||
|
_emailFocus.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -99,21 +119,32 @@ class _VerificationPageState extends State<VerificationPage> {
|
|||||||
].contains(status);
|
].contains(status);
|
||||||
final verified = status == VerificationStatus.verified;
|
final verified = status == VerificationStatus.verified;
|
||||||
final demo =
|
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) {
|
final title = switch (status) {
|
||||||
VerificationStatus.sent => l.checkInbox,
|
VerificationStatus.sent => demo ? l.demoSentHeading : l.checkInbox,
|
||||||
VerificationStatus.sending => l.verificationSending,
|
VerificationStatus.sending => l.verificationSending,
|
||||||
VerificationStatus.checking => l.verificationChecking,
|
VerificationStatus.checking => l.verificationChecking,
|
||||||
VerificationStatus.verified => l.verificationVerified,
|
VerificationStatus.verified =>
|
||||||
|
demo ? l.demoVerifiedHeading : l.verificationVerified,
|
||||||
VerificationStatus.expired => l.verificationExpired,
|
VerificationStatus.expired => l.verificationExpired,
|
||||||
VerificationStatus.invalid => l.verificationInvalid,
|
VerificationStatus.invalid => l.verificationInvalid,
|
||||||
VerificationStatus.error => l.verificationInternal,
|
VerificationStatus.error => l.verificationInternal,
|
||||||
VerificationStatus.idle =>
|
VerificationStatus.idle =>
|
||||||
widget.token != null ? l.verificationChecking : l.verificationRequest,
|
widget.token != null && !_lastWasSend
|
||||||
|
? l.verificationChecking
|
||||||
|
: l.verificationRequest,
|
||||||
};
|
};
|
||||||
final body = switch (status) {
|
final body = switch (status) {
|
||||||
VerificationStatus.sent => l.verificationSent,
|
VerificationStatus.sent => demo ? l.demoSentBody : l.verificationSent,
|
||||||
VerificationStatus.verified => l.verificationVerifiedBody,
|
VerificationStatus.verified =>
|
||||||
|
demo ? l.demoVerifiedBody : l.verificationVerifiedBody,
|
||||||
VerificationStatus.expired => l.verificationExpiredBody,
|
VerificationStatus.expired => l.verificationExpiredBody,
|
||||||
VerificationStatus.invalid => l.verificationInvalidBody,
|
VerificationStatus.invalid => l.verificationInvalidBody,
|
||||||
VerificationStatus.error => l.verificationInternalBody,
|
VerificationStatus.error => l.verificationInternalBody,
|
||||||
@@ -174,9 +205,17 @@ class _VerificationPageState extends State<VerificationPage> {
|
|||||||
color: colors.primary,
|
color: colors.primary,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
if (demo) ...[
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Text(
|
||||||
|
l.verificationDemo,
|
||||||
|
style: theme.textTheme.bodyMedium,
|
||||||
|
),
|
||||||
|
],
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
Semantics(
|
Semantics(
|
||||||
liveRegion: true,
|
liveRegion: true,
|
||||||
|
header: true,
|
||||||
child: Text(
|
child: Text(
|
||||||
title,
|
title,
|
||||||
key: ValueKey('verification-${status.name}'),
|
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),
|
const SizedBox(height: 16),
|
||||||
Text(
|
Text(
|
||||||
l.verificationHint,
|
l.verificationHint,
|
||||||
style: theme.textTheme.bodyMedium,
|
style: theme.textTheme.bodyMedium,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
if (!verified && status != VerificationStatus.checking) ...[
|
],
|
||||||
|
if (!verified &&
|
||||||
|
!sent &&
|
||||||
|
!retryConfirmation &&
|
||||||
|
status != VerificationStatus.checking) ...[
|
||||||
const SizedBox(height: 28),
|
const SizedBox(height: 28),
|
||||||
Form(
|
Form(
|
||||||
key: _form,
|
key: _form,
|
||||||
child: TextFormField(
|
child: TextFormField(
|
||||||
key: const ValueKey('verification-email'),
|
key: const ValueKey('verification-email'),
|
||||||
controller: _email,
|
controller: _email,
|
||||||
|
focusNode: _emailFocus,
|
||||||
|
autocorrect: false,
|
||||||
enabled: !state.isLoading,
|
enabled: !state.isLoading,
|
||||||
keyboardType: TextInputType.emailAddress,
|
keyboardType: TextInputType.emailAddress,
|
||||||
autofillHints: const [AutofillHints.email],
|
autofillHints: const [AutofillHints.email],
|
||||||
@@ -227,46 +308,28 @@ class _VerificationPageState extends State<VerificationPage> {
|
|||||||
child: Text(
|
child: Text(
|
||||||
state.isLoading
|
state.isLoading
|
||||||
? l.authWorking
|
? l.authWorking
|
||||||
: status == VerificationStatus.sent
|
|
||||||
? l.verificationResend
|
|
||||||
: l.verificationRequest,
|
: l.verificationRequest,
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
if (status == VerificationStatus.error &&
|
if (retryConfirmation) ...[
|
||||||
!_lastWasSend &&
|
|
||||||
widget.token != null) ...[
|
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
OutlinedButton(
|
FilledButton(
|
||||||
key: const ValueKey('verification-retry'),
|
key: const ValueKey('verification-retry'),
|
||||||
onPressed: () => _provider.confirm(widget.token!),
|
onPressed: () => _provider.confirm(widget.token!),
|
||||||
child: Text(l.verificationRetry),
|
child: Text(l.verificationRetry),
|
||||||
),
|
),
|
||||||
|
TextButton(
|
||||||
|
onPressed: _changeEmail,
|
||||||
|
child: Text(l.verificationRequest),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () => context.goNamed(AppRoutes.login),
|
onPressed: () => context.goNamed(AppRoutes.login),
|
||||||
child: Text(l.verificationBack),
|
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"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.4.1"
|
version: "1.4.1"
|
||||||
|
sxchnge_analytics:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
path: "../packages/sxchnge_analytics"
|
||||||
|
relative: true
|
||||||
|
source: path
|
||||||
|
version: "0.1.0"
|
||||||
sxchnge_auth:
|
sxchnge_auth:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ environment:
|
|||||||
sdk: '>=3.13.0 <4.0.0'
|
sdk: '>=3.13.0 <4.0.0'
|
||||||
flutter: '>=3.47.0'
|
flutter: '>=3.47.0'
|
||||||
dependencies:
|
dependencies:
|
||||||
|
sxchnge_analytics:
|
||||||
|
path: ../packages/sxchnge_analytics
|
||||||
flutter:
|
flutter:
|
||||||
sdk: flutter
|
sdk: flutter
|
||||||
sxchnge_auth:
|
sxchnge_auth:
|
||||||
|
|||||||
@@ -129,7 +129,7 @@ void main() {
|
|||||||
same(router),
|
same(router),
|
||||||
);
|
);
|
||||||
expect(find.byKey(const ValueKey('verification-verified')), findsOneWidget);
|
expect(find.byKey(const ValueKey('verification-verified')), findsOneWidget);
|
||||||
expect(find.text('Ваша почта\nподтверждена.'), findsOneWidget);
|
expect(find.text('Демоподтверждение\nзавершено.'), findsOneWidget);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets(
|
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.
|
`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.
|
`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,
|
VerificationStatus.sent,
|
||||||
);
|
);
|
||||||
Future<void> confirm(String token) => _run(
|
Future<void> confirm(String token) => _run(
|
||||||
() => token.trim().isEmpty
|
() => service.confirm(token: token),
|
||||||
? Future.error(const VerificationException(VerificationError.invalid))
|
|
||||||
: service.confirm(token: token),
|
|
||||||
VerificationStatus.checking,
|
VerificationStatus.checking,
|
||||||
VerificationStatus.verified,
|
VerificationStatus.verified,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
import '../models/auth.dart';
|
import '../models/auth.dart';
|
||||||
import 'contracts.dart';
|
import 'contracts.dart';
|
||||||
|
import 'verification.dart';
|
||||||
|
|
||||||
/// Deterministic local simulation. No network, storage, credentials or sessions
|
/// Deterministic local simulation. No network, storage, credentials or sessions
|
||||||
/// are retained. Configure failures explicitly rather than using magic emails.
|
/// 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({
|
const DummyLoginService({
|
||||||
this.delay = const Duration(milliseconds: 600),
|
this.delay = const Duration(milliseconds: 600),
|
||||||
this.failure,
|
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({
|
const DummySignupService({
|
||||||
this.delay = const Duration(milliseconds: 600),
|
this.delay = const Duration(milliseconds: 600),
|
||||||
this.failure,
|
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';
|
import '../models/verification.dart';
|
||||||
|
|
||||||
|
abstract interface class SimulationInfo {
|
||||||
|
bool get isSimulated;
|
||||||
|
}
|
||||||
|
|
||||||
abstract interface class VerificationService {
|
abstract interface class VerificationService {
|
||||||
Future<VerificationResult> send({
|
Future<VerificationResult> send({
|
||||||
required String email,
|
required String email,
|
||||||
@@ -9,11 +13,13 @@ abstract interface class VerificationService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Explicit preview tokens only. Never use this implementation in production.
|
/// Explicit preview tokens only. Never use this implementation in production.
|
||||||
class DummyVerificationService implements VerificationService {
|
class DummyVerificationService implements VerificationService, SimulationInfo {
|
||||||
const DummyVerificationService({
|
const DummyVerificationService({
|
||||||
this.delay = const Duration(milliseconds: 600),
|
this.delay = const Duration(milliseconds: 600),
|
||||||
this.sendFailure,
|
this.sendFailure,
|
||||||
});
|
});
|
||||||
|
@override
|
||||||
|
bool get isSimulated => true;
|
||||||
final Duration delay;
|
final Duration delay;
|
||||||
final VerificationError? sendFailure;
|
final VerificationError? sendFailure;
|
||||||
|
|
||||||
|
|||||||
@@ -10,3 +10,5 @@ export 'src/services/dummy.dart';
|
|||||||
export 'src/models/verification.dart';
|
export 'src/models/verification.dart';
|
||||||
export 'src/providers/verification.dart';
|
export 'src/providers/verification.dart';
|
||||||
export 'src/services/verification.dart';
|
export 'src/services/verification.dart';
|
||||||
|
|
||||||
|
export 'src/services/instrumented.dart';
|
||||||
@@ -6,6 +6,8 @@ environment:
|
|||||||
sdk: '>=3.13.0 <4.0.0'
|
sdk: '>=3.13.0 <4.0.0'
|
||||||
flutter: '>=3.47.0'
|
flutter: '>=3.47.0'
|
||||||
dependencies:
|
dependencies:
|
||||||
|
sxchnge_analytics:
|
||||||
|
path: ../sxchnge_analytics
|
||||||
flutter:
|
flutter:
|
||||||
sdk: flutter
|
sdk: flutter
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
|
|||||||
Reference in new issue
Block a user