Files
sxchnge/frontend/app/lib/verification/page.dart
T
Stephan D 10a278c65a Deliver localized auth app with verification and go_router
Integrate themed login/signup, EN/RU localization, shared dummy providers, verification recovery screens and named URL routes. Store frontend guidelines, workflow skills, validation and delivery documentation. Related to #2, #3, #5 and #6.
2026-09-11 15:37:07 +02:00

280 lines
10 KiB
Dart

import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:provider/provider.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>(),
);
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;
_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 (!(_form.currentState?.validate() ?? false)) return;
_lastWasSend = true;
_provider.send(
email: _email.text,
locale: Localizations.localeOf(context).languageCode,
);
}
@override
void dispose() {
_provider.dispose();
_email.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 DummyVerificationService || state.isSimulated;
final title = switch (status) {
VerificationStatus.sent => l.checkInbox,
VerificationStatus.sending => l.verificationSending,
VerificationStatus.checking => l.verificationChecking,
VerificationStatus.verified => l.verificationVerified,
VerificationStatus.expired => l.verificationExpired,
VerificationStatus.invalid => l.verificationInvalid,
VerificationStatus.error => l.verificationInternal,
VerificationStatus.idle =>
widget.token != null ? l.verificationChecking : l.verificationRequest,
};
final body = switch (status) {
VerificationStatus.sent => l.verificationSent,
VerificationStatus.verified => 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,
),
),
const SizedBox(height: 12),
Semantics(
liveRegion: 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 (status == VerificationStatus.sent) ...[
const SizedBox(height: 16),
Text(
l.verificationHint,
style: theme.textTheme.bodyMedium,
),
],
if (!verified && status != VerificationStatus.checking) ...[
const SizedBox(height: 28),
Form(
key: _form,
child: TextFormField(
key: const ValueKey('verification-email'),
controller: _email,
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
: status == VerificationStatus.sent
? l.verificationResend
: l.verificationRequest,
textAlign: TextAlign.center,
),
),
],
if (status == VerificationStatus.error &&
!_lastWasSend &&
widget.token != null) ...[
const SizedBox(height: 12),
OutlinedButton(
key: const ValueKey('verification-retry'),
onPressed: () => _provider.confirm(widget.token!),
child: Text(l.verificationRetry),
),
],
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),
),
],
],
),
),
),
),
),
);
},
);
}