Fix onboarding UX and add replaceable journey analytics

Make recipient changes explicit, prioritize verification retry, and label simulated outcomes prominently. Add shared typed analytics with bounded dummy capture, correlated service events and documented PostHog adapter contract. Validate privacy, retries, abandonment, keyboard recovery and accessibility. Related to #12.
This commit is contained in:
Stephan D committed 2026-09-12 08:36:45 +02:00
1 parent bf7778f312
commit f1cbf64282
28 files changed
+1158 -64

No files matched your search

+8 -8
View File
@@ -4,6 +4,8 @@ import 'package:go_router/go_router.dart';
import 'package:provider/provider.dart';
import 'package:sxchnge_analytics/sxchnge_analytics.dart';
import 'package:sxchnge_auth/sxchnge_auth.dart';
import '../routing/routes.dart';
@@ -58,6 +60,7 @@ class _AuthFormState extends State<AuthForm> {
void _switch(bool signUp) {
if (signUp == _signUp) return;
context.read<JourneyRecorder>().abandon(JourneyExit.modeChange);
context.read<LoginProvider>().reset();
context.read<SignupProvider>().reset();
_form.currentState?.reset();
@@ -130,6 +133,11 @@ class _AuthFormState extends State<AuthForm> {
),
),
),
const SizedBox(height: 16),
Text(
l.earlyPreview,
style: text.bodySmall!.copyWith(color: colors.onSurfaceVariant),
),
const SizedBox(height: 24),
Text(
_signUp ? l.signupHeading : l.loginHeading,
@@ -316,14 +324,6 @@ class _AuthFormState extends State<AuthForm> {
),
],
),
const SizedBox(height: 24),
const Divider(),
const SizedBox(height: 16),
Text(
l.earlyPreview,
textAlign: TextAlign.center,
style: text.bodySmall!.copyWith(color: colors.onSurfaceVariant),
),
],
);
}
@@ -461,7 +461,7 @@ abstract class AppLocalizations {
/// No description provided for @verificationRetry.
///
/// In en, this message translates to:
/// **'Try again'**
/// **'Retry verification'**
String get verificationRetry;
/// No description provided for @verificationBack.
@@ -493,6 +493,48 @@ abstract class AppLocalizations {
/// In en, this message translates to:
/// **'CONFIRM YOUR EMAIL'**
String get verificationStep;
/// No description provided for @demoSentHeading.
///
/// In en, this message translates to:
/// **'Demo email prepared.'**
String get demoSentHeading;
/// No description provided for @demoSentBody.
///
/// In en, this message translates to:
/// **'No email was sent. Use the demo confirmation below to preview the next step.'**
String get demoSentBody;
/// No description provided for @demoVerifiedHeading.
///
/// In en, this message translates to:
/// **'Demo verification\ncomplete.'**
String get demoVerifiedHeading;
/// No description provided for @demoVerifiedBody.
///
/// In en, this message translates to:
/// **'You’ve previewed email confirmation. No account was verified. Return to login to explore the demo.'**
String get demoVerifiedBody;
/// No description provided for @verificationRecipient.
///
/// In en, this message translates to:
/// **'Recipient'**
String get verificationRecipient;
/// No description provided for @verificationChangeEmail.
///
/// In en, this message translates to:
/// **'Change email'**
String get verificationChangeEmail;
/// No description provided for @demoResend.
///
/// In en, this message translates to:
/// **'Prepare demo email again'**
String get demoResend;
}
class _AppLocalizationsDelegate
@@ -201,7 +201,7 @@ class AppLocalizationsEn extends AppLocalizations {
String get verificationRequest => 'Send a new verification email';
@override
String get verificationRetry => 'Try again';
String get verificationRetry => 'Retry verification';
@override
String get verificationBack => 'Back to login';
@@ -219,4 +219,27 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get verificationStep => 'CONFIRM YOUR EMAIL';
@override
String get demoSentHeading => 'Demo email prepared.';
@override
String get demoSentBody =>
'No email was sent. Use the demo confirmation below to preview the next step.';
@override
String get demoVerifiedHeading => 'Demo verification\ncomplete.';
@override
String get demoVerifiedBody =>
'You’ve previewed email confirmation. No account was verified. Return to login to explore the demo.';
@override
String get verificationRecipient => 'Recipient';
@override
String get verificationChangeEmail => 'Change email';
@override
String get demoResend => 'Prepare demo email again';
}
@@ -202,7 +202,7 @@ class AppLocalizationsRu extends AppLocalizations {
String get verificationRequest => 'Отправить новое письмо';
@override
String get verificationRetry => 'Попробовать ещё раз';
String get verificationRetry => 'Повторить подтверждение';
@override
String get verificationBack => 'Вернуться ко входу';
@@ -220,4 +220,27 @@ class AppLocalizationsRu extends AppLocalizations {
@override
String get verificationStep => 'ПОДТВЕРДИТЕ ПОЧТУ';
@override
String get demoSentHeading => 'Демописьмо готово.';
@override
String get demoSentBody =>
'Письмо не отправлено. Используйте демоссылку ниже, чтобы посмотреть следующий шаг.';
@override
String get demoVerifiedHeading => 'Демоподтверждение\nзавершено.';
@override
String get demoVerifiedBody =>
'Вы посмотрели подтверждение почты. Реальный аккаунт не подтверждён. Вернитесь ко входу, чтобы продолжить знакомство с демоверсией.';
@override
String get verificationRecipient => 'Получатель';
@override
String get verificationChangeEmail => 'Изменить почту';
@override
String get demoResend => 'Подготовить демописьмо ещё раз';
}
+9 -2
View File
@@ -60,10 +60,17 @@
"verificationInternalBody": "We couldn’t complete your request because of an internal error. Please try again.",
"verificationResend": "Resend verification email",
"verificationRequest": "Send a new verification email",
"verificationRetry": "Try again",
"verificationRetry": "Retry verification",
"verificationBack": "Back to login",
"verificationHint": "No email? Check your spam folder or request another below.",
"verificationDemo": "DEMO · No real email is sent and no account is verified.",
"verificationDemoLink": "Open demo confirmation link",
"verificationStep": "CONFIRM YOUR EMAIL"
"verificationStep": "CONFIRM YOUR EMAIL",
"demoSentHeading": "Demo email prepared.",
"demoSentBody": "No email was sent. Use the demo confirmation below to preview the next step.",
"demoVerifiedHeading": "Demo verification\ncomplete.",
"demoVerifiedBody": "You’ve previewed email confirmation. No account was verified. Return to login to explore the demo.",
"verificationRecipient": "Recipient",
"verificationChangeEmail": "Change email",
"demoResend": "Prepare demo email again"
}
+9 -2
View File
@@ -60,10 +60,17 @@
"verificationInternalBody": "Не удалось выполнить запрос из-за внутренней ошибки. Попробуйте ещё раз.",
"verificationResend": "Отправить письмо ещё раз",
"verificationRequest": "Отправить новое письмо",
"verificationRetry": "Попробовать ещё раз",
"verificationRetry": "Повторить подтверждение",
"verificationBack": "Вернуться ко входу",
"verificationHint": "Нет письма? Проверьте папку «Спам» или запросите новое ниже.",
"verificationDemo": "ДЕМО · Письмо не отправляется, реальный аккаунт не подтверждается.",
"verificationDemoLink": "Открыть демоссылку подтверждения",
"verificationStep": "ПОДТВЕРДИТЕ ПОЧТУ"
"verificationStep": "ПОДТВЕРДИТЕ ПОЧТУ",
"demoSentHeading": "Демописьмо готово.",
"demoSentBody": "Письмо не отправлено. Используйте демоссылку ниже, чтобы посмотреть следующий шаг.",
"demoVerifiedHeading": "Демоподтверждение\nзавершено.",
"demoVerifiedBody": "Вы посмотрели подтверждение почты. Реальный аккаунт не подтверждён. Вернитесь ко входу, чтобы продолжить знакомство с демоверсией.",
"verificationRecipient": "Получатель",
"verificationChangeEmail": "Изменить почту",
"demoResend": "Подготовить демописьмо ещё раз"
}
+36 -6
View File
@@ -2,6 +2,8 @@ import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:sxchnge_analytics/sxchnge_analytics.dart';
import 'package:sxchnge_auth/sxchnge_auth.dart';
import 'package:sxchnge_theme/sxchnge_theme.dart';
@@ -21,11 +23,13 @@ class SxchngeApp extends StatefulWidget {
const SxchngeApp({
super.key,
this.localeProvider,
this.analyticsService,
this.loginService = const DummyLoginService(),
this.signupService = const DummySignupService(),
this.verificationService = const DummyVerificationService(),
});
final LocaleProvider? localeProvider;
final AnalyticsService? analyticsService;
final LoginService loginService;
final SignupService signupService;
final VerificationService verificationService;
@@ -37,11 +41,38 @@ class SxchngeApp extends StatefulWidget {
class _SxchngeAppState extends State<SxchngeApp> {
late final _locale = widget.localeProvider ?? LocaleProvider();
late final _router = createAppRouter();
late final _journeys = JourneyRecorder(
widget.analyticsService ?? DummyAnalyticsService(),
);
late final _services = InstrumentedAuthServices(
recorder: _journeys,
loginService: widget.loginService,
signupService: widget.signupService,
verificationService: widget.verificationService,
);
late final _router = createAppRouter()
..routeInformationProvider.addListener(_routeChanged);
String _previousPath = '/';
@override
void initState() {
super.initState();
_previousPath = _router.routeInformationProvider.value.uri.path;
}
void _routeChanged() {
final path = _router.routeInformationProvider.value.uri.path;
if (path == '/' && _previousPath != '/') {
_journeys.abandon(JourneyExit.navigation);
}
_previousPath = path;
}
@override
void dispose() {
_router.routeInformationProvider.removeListener(_routeChanged);
_router.dispose();
_journeys.dispose();
_locale.dispose();
super.dispose();
}
@@ -49,12 +80,11 @@ class _SxchngeAppState extends State<SxchngeApp> {
@override
Widget build(BuildContext context) => MultiProvider(
providers: [
Provider<JourneyRecorder>.value(value: _journeys),
ChangeNotifierProvider.value(value: _locale),
Provider<VerificationService>.value(value: widget.verificationService),
ChangeNotifierProvider(create: (_) => LoginProvider(widget.loginService)),
ChangeNotifierProvider(
create: (_) => SignupProvider(widget.signupService),
),
Provider<VerificationService>.value(value: _services),
ChangeNotifierProvider(create: (_) => LoginProvider(_services)),
ChangeNotifierProvider(create: (_) => SignupProvider(_services)),
],
child: Consumer<LocaleProvider>(
builder: (context, locale, _) => MaterialApp.router(
+99 -36
View File
@@ -4,6 +4,8 @@ import 'package:go_router/go_router.dart';
import 'package:provider/provider.dart';
import 'package:sxchnge_analytics/sxchnge_analytics.dart';
import 'package:sxchnge_auth/sxchnge_auth.dart';
import '../auth/brand_panel.dart';
@@ -28,6 +30,8 @@ class _VerificationPageState extends State<VerificationPage> {
late final _provider = VerificationProvider(
context.read<VerificationService>(),
);
final _emailFocus = FocusNode();
String? _recipient;
bool _initialized = false;
bool _lastWasSend = false;
bool _validated = false;
@@ -56,6 +60,7 @@ class _VerificationPageState extends State<VerificationPage> {
void didUpdateWidget(covariant VerificationPage oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.token == widget.token) return;
context.read<JourneyRecorder>().abandon(JourneyExit.navigation);
_provider.reset();
_lastWasSend = false;
_validated = false;
@@ -68,7 +73,12 @@ class _VerificationPageState extends State<VerificationPage> {
void _send() {
if (_provider.state.isLoading) return;
_validated = true;
if (!(_form.currentState?.validate() ?? false)) return;
if (_provider.state.status != VerificationStatus.sent &&
!(_form.currentState?.validate() ?? false)) {
return;
}
_recipient = _email.text.trim();
FocusScope.of(context).unfocus();
_lastWasSend = true;
_provider.send(
email: _email.text,
@@ -76,10 +86,20 @@ class _VerificationPageState extends State<VerificationPage> {
);
}
void _changeEmail() {
_lastWasSend = true;
_validated = false;
_provider.reset();
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) _emailFocus.requestFocus();
});
}
@override
void dispose() {
_provider.dispose();
_email.dispose();
_emailFocus.dispose();
super.dispose();
}
@@ -99,21 +119,32 @@ class _VerificationPageState extends State<VerificationPage> {
].contains(status);
final verified = status == VerificationStatus.verified;
final demo =
_provider.service is DummyVerificationService || state.isSimulated;
(_provider.service is SimulationInfo &&
(_provider.service as SimulationInfo).isSimulated) ||
state.isSimulated;
final sent = status == VerificationStatus.sent;
final retryConfirmation =
status == VerificationStatus.error &&
!_lastWasSend &&
widget.token != null;
final title = switch (status) {
VerificationStatus.sent => l.checkInbox,
VerificationStatus.sent => demo ? l.demoSentHeading : l.checkInbox,
VerificationStatus.sending => l.verificationSending,
VerificationStatus.checking => l.verificationChecking,
VerificationStatus.verified => l.verificationVerified,
VerificationStatus.verified =>
demo ? l.demoVerifiedHeading : l.verificationVerified,
VerificationStatus.expired => l.verificationExpired,
VerificationStatus.invalid => l.verificationInvalid,
VerificationStatus.error => l.verificationInternal,
VerificationStatus.idle =>
widget.token != null ? l.verificationChecking : l.verificationRequest,
widget.token != null && !_lastWasSend
? l.verificationChecking
: l.verificationRequest,
};
final body = switch (status) {
VerificationStatus.sent => l.verificationSent,
VerificationStatus.verified => l.verificationVerifiedBody,
VerificationStatus.sent => demo ? l.demoSentBody : l.verificationSent,
VerificationStatus.verified =>
demo ? l.demoVerifiedBody : l.verificationVerifiedBody,
VerificationStatus.expired => l.verificationExpiredBody,
VerificationStatus.invalid => l.verificationInvalidBody,
VerificationStatus.error => l.verificationInternalBody,
@@ -174,9 +205,17 @@ class _VerificationPageState extends State<VerificationPage> {
color: colors.primary,
),
),
if (demo) ...[
const SizedBox(height: 12),
Text(
l.verificationDemo,
style: theme.textTheme.bodyMedium,
),
],
const SizedBox(height: 12),
Semantics(
liveRegion: true,
header: true,
child: Text(
title,
key: ValueKey('verification-${status.name}'),
@@ -192,20 +231,62 @@ class _VerificationPageState extends State<VerificationPage> {
),
),
],
if (status == VerificationStatus.sent) ...[
const SizedBox(height: 16),
if (sent) ...[
const SizedBox(height: 20),
Text(
l.verificationHint,
style: theme.textTheme.bodyMedium,
l.verificationRecipient,
style: theme.textTheme.labelLarge,
),
Text(
_recipient ?? _email.text,
key: const ValueKey('verification-recipient'),
),
TextButton(
key: const ValueKey('verification-change-email'),
onPressed: _changeEmail,
child: Text(l.verificationChangeEmail),
),
if (demo)
FilledButton(
key: const ValueKey('demo-confirmation'),
onPressed: () => context.goNamed(
AppRoutes.verifyEmail,
queryParameters: {'token': 'demo-valid'},
),
child: Text(
l.verificationDemoLink,
textAlign: TextAlign.center,
),
),
const SizedBox(height: 12),
OutlinedButton(
key: const ValueKey('verification-resend'),
onPressed: _send,
child: Text(
demo ? l.demoResend : l.verificationResend,
textAlign: TextAlign.center,
),
),
if (!demo) ...[
const SizedBox(height: 16),
Text(
l.verificationHint,
style: theme.textTheme.bodyMedium,
),
],
],
if (!verified && status != VerificationStatus.checking) ...[
if (!verified &&
!sent &&
!retryConfirmation &&
status != VerificationStatus.checking) ...[
const SizedBox(height: 28),
Form(
key: _form,
child: TextFormField(
key: const ValueKey('verification-email'),
controller: _email,
focusNode: _emailFocus,
autocorrect: false,
enabled: !state.isLoading,
keyboardType: TextInputType.emailAddress,
autofillHints: const [AutofillHints.email],
@@ -227,46 +308,28 @@ class _VerificationPageState extends State<VerificationPage> {
child: Text(
state.isLoading
? l.authWorking
: status == VerificationStatus.sent
? l.verificationResend
: l.verificationRequest,
textAlign: TextAlign.center,
),
),
],
if (status == VerificationStatus.error &&
!_lastWasSend &&
widget.token != null) ...[
if (retryConfirmation) ...[
const SizedBox(height: 12),
OutlinedButton(
FilledButton(
key: const ValueKey('verification-retry'),
onPressed: () => _provider.confirm(widget.token!),
child: Text(l.verificationRetry),
),
TextButton(
onPressed: _changeEmail,
child: Text(l.verificationRequest),
),
],
const SizedBox(height: 16),
TextButton(
onPressed: () => context.goNamed(AppRoutes.login),
child: Text(l.verificationBack),
),
if (demo) ...[
const SizedBox(height: 24),
Text(
l.verificationDemo,
style: theme.textTheme.bodySmall?.copyWith(
color: colors.onSurfaceVariant,
),
),
if (status == VerificationStatus.sent)
TextButton(
key: const ValueKey('demo-confirmation'),
onPressed: () => context.goNamed(
AppRoutes.verifyEmail,
queryParameters: {'token': 'demo-valid'},
),
child: Text(l.verificationDemoLink),
),
],
],
),
),