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.
280 lines
10 KiB
Dart
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),
|
|
),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
},
|
|
);
|
|
}
|