Files
sxchnge/frontend/app/lib/verification/page.dart
T

350 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),
),
],
if (verified) ...[
const SizedBox(height: 16),
FilledButton(
onPressed: () => context.goNamed(AppRoutes.identity),
child: Text(l.kycPreview),
),
],
const SizedBox(height: 16),
TextButton(
onPressed: () => context.goNamed(AppRoutes.login),
child: Text(l.verificationBack),
),
],
),
),
),
),
),
);
},
);
}