Files
sxchnge/frontend/app/lib/verification/page.dart
T
Stephan D f1cbf64282 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.
2026-09-12 08:36:45 +02:00

343 lines
13 KiB
Dart

import 'package:flutter/material.dart';
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';
import '../auth/validation.dart';
import '../routing/routes.dart';
import '../widgets/locale.dart';
import '../generated/i18n/app_localizations.dart';
class VerificationPage extends StatefulWidget {
const VerificationPage({super.key, this.email, this.token});
final String? email;
final String? token;
@override
State<VerificationPage> createState() => _VerificationPageState();
}
class _VerificationPageState extends State<VerificationPage> {
final _form = GlobalKey<FormState>();
late final _email = TextEditingController(text: widget.email);
late final _provider = VerificationProvider(
context.read<VerificationService>(),
);
final _emailFocus = FocusNode();
String? _recipient;
bool _initialized = false;
bool _lastWasSend = false;
bool _validated = false;
@override
void didChangeDependencies() {
super.didChangeDependencies();
if (!_initialized) {
_initialized = true;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
if (widget.token != null) {
_provider.confirm(widget.token!);
} else if (_email.text.isNotEmpty) {
_send();
}
});
} else if (_validated) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) _form.currentState?.validate();
});
}
}
@override
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;
_email.clear();
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted && widget.token != null) _provider.confirm(widget.token!);
});
}
void _send() {
if (_provider.state.isLoading) return;
_validated = true;
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,
locale: Localizations.localeOf(context).languageCode,
);
}
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();
}
@override
Widget build(BuildContext context) => ListenableBuilder(
listenable: _provider,
builder: (context, _) {
final l = AppLocalizations.of(context)!;
final theme = Theme.of(context);
final colors = theme.colorScheme;
final state = _provider.state;
final status = state.status;
final failed = [
VerificationStatus.expired,
VerificationStatus.invalid,
VerificationStatus.error,
].contains(status);
final verified = status == VerificationStatus.verified;
final demo =
(_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 => demo ? l.demoSentHeading : l.checkInbox,
VerificationStatus.sending => l.verificationSending,
VerificationStatus.checking => l.verificationChecking,
VerificationStatus.verified =>
demo ? l.demoVerifiedHeading : l.verificationVerified,
VerificationStatus.expired => l.verificationExpired,
VerificationStatus.invalid => l.verificationInvalid,
VerificationStatus.error => l.verificationInternal,
VerificationStatus.idle =>
widget.token != null && !_lastWasSend
? l.verificationChecking
: l.verificationRequest,
};
final body = switch (status) {
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,
_ => null,
};
return Scaffold(
body: SafeArea(
child: SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 520),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Align(
alignment: Alignment.centerRight,
child: LocaleSelector(),
),
const Align(
alignment: Alignment.centerLeft,
child: BrandWordmark(),
),
const SizedBox(height: 48),
Align(
alignment: Alignment.centerLeft,
child: Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: failed
? colors.errorContainer
: colors.primaryContainer,
borderRadius: BorderRadius.circular(24),
),
child: state.isLoading
? const SizedBox(
width: 32,
height: 32,
child: CircularProgressIndicator(),
)
: Icon(
verified
? Icons.mark_email_read_outlined
: failed
? Icons.error_outline
: Icons.mark_email_unread_outlined,
size: 32,
color: failed
? colors.onErrorContainer
: colors.onPrimaryContainer,
),
),
),
const SizedBox(height: 28),
Text(
l.verificationStep,
style: theme.textTheme.labelLarge?.copyWith(
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}'),
style: theme.textTheme.displaySmall,
),
),
if (body != null) ...[
const SizedBox(height: 20),
Text(
body,
style: theme.textTheme.bodyLarge?.copyWith(
color: colors.onSurfaceVariant,
),
),
],
if (sent) ...[
const SizedBox(height: 20),
Text(
l.verificationRecipient,
style: theme.textTheme.labelLarge,
),
Text(
_recipient ?? _email.text,
key: const ValueKey('verification-recipient'),
),
TextButton(
key: const ValueKey('verification-change-email'),
onPressed: _changeEmail,
child: Text(l.verificationChangeEmail),
),
if (demo)
FilledButton(
key: const ValueKey('demo-confirmation'),
onPressed: () => context.goNamed(
AppRoutes.verifyEmail,
queryParameters: {'token': 'demo-valid'},
),
child: Text(
l.verificationDemoLink,
textAlign: TextAlign.center,
),
),
const SizedBox(height: 12),
OutlinedButton(
key: const ValueKey('verification-resend'),
onPressed: _send,
child: Text(
demo ? l.demoResend : l.verificationResend,
textAlign: TextAlign.center,
),
),
if (!demo) ...[
const SizedBox(height: 16),
Text(
l.verificationHint,
style: theme.textTheme.bodyMedium,
),
],
],
if (!verified &&
!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],
textInputAction: TextInputAction.send,
validator: (value) => AuthValidation.email(value, l),
onFieldSubmitted: (_) => _send(),
decoration: InputDecoration(
labelText: l.email,
hintText: l.emailHint,
errorMaxLines: 3,
prefixIcon: const Icon(Icons.alternate_email),
),
),
),
const SizedBox(height: 16),
FilledButton(
key: const ValueKey('verification-resend'),
onPressed: state.isLoading ? null : _send,
child: Text(
state.isLoading
? l.authWorking
: l.verificationRequest,
textAlign: TextAlign.center,
),
),
],
if (retryConfirmation) ...[
const SizedBox(height: 12),
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),
),
],
),
),
),
),
),
);
},
);
}