Add reusable two-factor verification for login and money operations (#32)
This commit is contained in:
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),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user