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

+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),
),
],
],
),
),