Add reusable two-factor verification for login and money operations (#32)

This commit is contained in:
Stephan D committed 2026-09-12 12:44:34 +02:00
1 parent b7a58b1a87
commit fb40f860d2
41 files changed
+2439 -43

No files matched your search

@@ -0,0 +1,200 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../models/two_factor.dart';
import '../providers/two_factor.dart';
/// Labels are supplied by each client's localization layer.
class TwoFactorLabels {
const TwoFactorLabels({
required this.title,
required this.description,
required this.code,
required this.confirm,
required this.cancel,
required this.resend,
required this.expired,
required this.help,
required this.errorText,
required this.cooldownText,
});
final String title, description, code, confirm, cancel, resend, expired, help;
final String Function(FactorError) errorText;
final String Function(int) cooldownText;
}
/// Reusable full-page code entry, owned by the caller's route/provider lifecycle.
class TwoFactorPage extends StatefulWidget {
const TwoFactorPage({
super.key,
required this.provider,
required this.labels,
required this.onVerified,
required this.onCancel,
this.summary,
});
final TwoFactorProvider provider;
final TwoFactorLabels labels;
final ValueChanged<FactorGrant> onVerified;
final VoidCallback onCancel;
final Widget? summary;
@override
State<TwoFactorPage> createState() => _TwoFactorPageState();
}
class _TwoFactorPageState extends State<TwoFactorPage> {
final _code = TextEditingController();
final _focus = FocusNode();
bool _delivered = false;
@override
void initState() {
super.initState();
widget.provider.addListener(_changed);
}
void _changed() {
final p = widget.provider;
if (p.result != null && !_delivered) {
_delivered = true;
_code.clear();
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) widget.onVerified(p.result!);
});
}
}
void _submit() {
widget.provider.submit(_code.text.trim());
}
@override
void dispose() {
widget.provider.removeListener(_changed);
_code.dispose();
_focus.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) => ListenableBuilder(
listenable: widget.provider,
builder: (context, _) {
final p = widget.provider, l = widget.labels;
final t = Theme.of(context), c = Theme.of(context).colorScheme;
return PopScope(
canPop: !p.busy,
child: Scaffold(
body: SafeArea(
child: Center(
child: SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 480),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Align(
alignment: Alignment.centerLeft,
child: CircleAvatar(
radius: 32,
backgroundColor: c.primaryContainer,
child: Icon(
Icons.verified_user_outlined,
size: 30,
color: c.onPrimaryContainer,
),
),
),
const SizedBox(height: 24),
Semantics(
header: true,
child: Text(l.title, style: t.textTheme.displaySmall),
),
const SizedBox(height: 16),
Text(l.description, style: t.textTheme.bodyLarge),
if (p.challenge.method == FactorMethod.email) ...[
const SizedBox(height: 12),
Text(p.challenge.target),
],
if (widget.summary != null) ...[
const SizedBox(height: 20),
widget.summary!,
],
const SizedBox(height: 28),
if (p.busy) const LinearProgressIndicator(),
TextField(
key: const ValueKey('two-factor-code'),
controller: _code,
focusNode: _focus,
autofocus: true,
enabled: !p.busy && !p.locked && !p.expired,
keyboardType: TextInputType.number,
textAlign: TextAlign.center,
style: t.textTheme.headlineMedium?.copyWith(
letterSpacing: 8,
),
autofillHints: const [AutofillHints.oneTimeCode],
inputFormatters: [
FilteringTextInputFormatter.digitsOnly,
LengthLimitingTextInputFormatter(6),
],
textInputAction: TextInputAction.done,
onSubmitted: (_) => _submit(),
decoration: InputDecoration(labelText: l.code),
),
if (p.error != null || p.expired) ...[
const SizedBox(height: 12),
Semantics(
liveRegion: true,
child: Text(
p.error == null ? l.expired : l.errorText(p.error!),
key: const ValueKey('two-factor-error'),
style: TextStyle(color: c.error),
),
),
],
const SizedBox(height: 24),
FilledButton(
key: const ValueKey('two-factor-confirm'),
onPressed: p.busy || p.expired || p.locked
? null
: _submit,
child: Text(l.confirm),
),
const SizedBox(height: 12),
if (p.challenge.method == FactorMethod.email)
TextButton(
key: const ValueKey('two-factor-resend'),
onPressed: p.busy || p.cooldown > 0 || p.locked
? null
: () async {
await p.resend();
if (mounted && p.error == null) {
_code.clear();
_focus.requestFocus();
}
},
child: Text(
p.cooldown > 0
? l.cooldownText(p.cooldown)
: l.resend,
),
),
Text(l.help, style: t.textTheme.bodySmall),
const SizedBox(height: 16),
TextButton(
key: const ValueKey('two-factor-cancel'),
onPressed: p.busy ? null : widget.onCancel,
child: Text(l.cancel),
),
],
),
),
),
),
),
),
);
},
);
}