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