346 lines
13 KiB
Dart
346 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 '../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 needsAssistance = state.error == VerificationError.needsAssistance;
|
|
final retryConfirmation =
|
|
status == VerificationStatus.error &&
|
|
!needsAssistance &&
|
|
!_lastWasSend &&
|
|
widget.token != null;
|
|
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 && !_lastWasSend
|
|
? l.verificationChecking
|
|
: l.verificationRequest,
|
|
};
|
|
final body = needsAssistance
|
|
? l.authSignupNeedsAssistance
|
|
: 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.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,
|
|
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(
|
|
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(
|
|
key: const ValueKey('verification-continue'),
|
|
onPressed: () => context.goNamed(
|
|
AppRoutes.login,
|
|
extra: state.result?.verifiedEmail,
|
|
),
|
|
child: Text(l.verificationContinue),
|
|
),
|
|
],
|
|
if (!verified) ...[
|
|
const SizedBox(height: 16),
|
|
TextButton(
|
|
onPressed: () => context.goNamed(AppRoutes.login),
|
|
child: Text(l.verificationBack),
|
|
),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
},
|
|
);
|
|
}
|