190 lines
5.1 KiB
Dart
190 lines
5.1 KiB
Dart
|
|
import 'package:flutter/material.dart';
|
||
|
|
import 'package:flutter/services.dart';
|
||
|
|
|
||
|
|
|
||
|
|
class OtpCodeInput extends StatefulWidget {
|
||
|
|
|
||
|
|
final int length;
|
||
|
|
|
||
|
|
|
||
|
|
final ValueChanged<String> onChanged;
|
||
|
|
|
||
|
|
|
||
|
|
final ValueChanged<String>? onCompleted;
|
||
|
|
|
||
|
|
|
||
|
|
final bool enabled;
|
||
|
|
|
||
|
|
|
||
|
|
final bool autofocus;
|
||
|
|
|
||
|
|
|
||
|
|
final Color foreground;
|
||
|
|
|
||
|
|
|
||
|
|
final Color accent;
|
||
|
|
|
||
|
|
const OtpCodeInput({
|
||
|
|
super.key,
|
||
|
|
this.length = 6,
|
||
|
|
required this.onChanged,
|
||
|
|
this.onCompleted,
|
||
|
|
this.enabled = true,
|
||
|
|
this.autofocus = true,
|
||
|
|
this.foreground = Colors.white,
|
||
|
|
this.accent = const Color(0xFF1677FF),
|
||
|
|
});
|
||
|
|
|
||
|
|
@override
|
||
|
|
State<OtpCodeInput> createState() => _OtpCodeInputState();
|
||
|
|
}
|
||
|
|
|
||
|
|
class _OtpCodeInputState extends State<OtpCodeInput> {
|
||
|
|
late final List<TextEditingController> _controllers;
|
||
|
|
late final List<FocusNode> _nodes;
|
||
|
|
|
||
|
|
@override
|
||
|
|
void initState() {
|
||
|
|
super.initState();
|
||
|
|
_controllers = List.generate(widget.length, (_) => TextEditingController());
|
||
|
|
_nodes = List.generate(widget.length, (_) {
|
||
|
|
final node = FocusNode();
|
||
|
|
node.addListener(() {
|
||
|
|
if (node.hasFocus) {
|
||
|
|
final i = _nodes.indexOf(node);
|
||
|
|
final c = _controllers[i];
|
||
|
|
c.selection = TextSelection(
|
||
|
|
baseOffset: 0,
|
||
|
|
extentOffset: c.text.length,
|
||
|
|
);
|
||
|
|
}
|
||
|
|
if (mounted) setState(() {});
|
||
|
|
});
|
||
|
|
return node;
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
@override
|
||
|
|
void dispose() {
|
||
|
|
for (final c in _controllers) {
|
||
|
|
c.dispose();
|
||
|
|
}
|
||
|
|
for (final n in _nodes) {
|
||
|
|
n.dispose();
|
||
|
|
}
|
||
|
|
super.dispose();
|
||
|
|
}
|
||
|
|
|
||
|
|
String get _value => _controllers.map((c) => c.text).join();
|
||
|
|
|
||
|
|
void _emit() {
|
||
|
|
final v = _value;
|
||
|
|
widget.onChanged(v);
|
||
|
|
if (v.length == widget.length) widget.onCompleted?.call(v);
|
||
|
|
}
|
||
|
|
|
||
|
|
|
||
|
|
void _spread(int startIndex, String digits) {
|
||
|
|
var idx = startIndex;
|
||
|
|
for (final ch in digits.split('')) {
|
||
|
|
if (idx >= widget.length) break;
|
||
|
|
_controllers[idx].text = ch;
|
||
|
|
idx++;
|
||
|
|
}
|
||
|
|
final focusIndex = idx >= widget.length ? widget.length - 1 : idx;
|
||
|
|
_nodes[focusIndex].requestFocus();
|
||
|
|
_emit();
|
||
|
|
}
|
||
|
|
|
||
|
|
void _onChanged(int index, String raw) {
|
||
|
|
final digits = raw.replaceAll(RegExp(r'\D'), '');
|
||
|
|
if (digits.isEmpty) {
|
||
|
|
_controllers[index].text = '';
|
||
|
|
_emit();
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
if (digits.length == 1) {
|
||
|
|
final c = _controllers[index];
|
||
|
|
c.text = digits;
|
||
|
|
c.selection = TextSelection.collapsed(offset: c.text.length);
|
||
|
|
if (index < widget.length - 1) _nodes[index + 1].requestFocus();
|
||
|
|
_emit();
|
||
|
|
} else {
|
||
|
|
_spread(index, digits);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
KeyEventResult _onKey(int index, KeyEvent event) {
|
||
|
|
if (event is KeyDownEvent &&
|
||
|
|
event.logicalKey == LogicalKeyboardKey.backspace &&
|
||
|
|
_controllers[index].text.isEmpty &&
|
||
|
|
index > 0) {
|
||
|
|
_controllers[index - 1].text = '';
|
||
|
|
_nodes[index - 1].requestFocus();
|
||
|
|
_emit();
|
||
|
|
return KeyEventResult.handled;
|
||
|
|
}
|
||
|
|
return KeyEventResult.ignored;
|
||
|
|
}
|
||
|
|
|
||
|
|
@override
|
||
|
|
Widget build(BuildContext context) {
|
||
|
|
return Row(
|
||
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
||
|
|
children: List.generate(widget.length, (i) {
|
||
|
|
final focused = _nodes[i].hasFocus;
|
||
|
|
final filled = _controllers[i].text.isNotEmpty;
|
||
|
|
final borderColor = focused
|
||
|
|
? widget.accent
|
||
|
|
: widget.foreground.withValues(alpha: filled ? 0.45 : 0.18);
|
||
|
|
return Padding(
|
||
|
|
padding: const EdgeInsets.symmetric(horizontal: 5),
|
||
|
|
child: SizedBox(
|
||
|
|
width: 46,
|
||
|
|
height: 56,
|
||
|
|
child: Focus(
|
||
|
|
canRequestFocus: false,
|
||
|
|
skipTraversal: true,
|
||
|
|
onKeyEvent: (_, event) => _onKey(i, event),
|
||
|
|
child: TextField(
|
||
|
|
controller: _controllers[i],
|
||
|
|
focusNode: _nodes[i],
|
||
|
|
enabled: widget.enabled,
|
||
|
|
autofocus: widget.autofocus && i == 0,
|
||
|
|
textAlign: TextAlign.center,
|
||
|
|
keyboardType: TextInputType.number,
|
||
|
|
cursorColor: widget.accent,
|
||
|
|
showCursor: true,
|
||
|
|
style: TextStyle(
|
||
|
|
color: widget.foreground,
|
||
|
|
fontSize: 22,
|
||
|
|
fontWeight: FontWeight.w600,
|
||
|
|
),
|
||
|
|
decoration: InputDecoration(
|
||
|
|
counterText: '',
|
||
|
|
filled: true,
|
||
|
|
fillColor: widget.foreground.withValues(alpha: 0.06),
|
||
|
|
contentPadding: EdgeInsets.zero,
|
||
|
|
enabledBorder: OutlineInputBorder(
|
||
|
|
borderRadius: BorderRadius.circular(10),
|
||
|
|
borderSide: BorderSide(color: borderColor, width: 1.2),
|
||
|
|
),
|
||
|
|
focusedBorder: OutlineInputBorder(
|
||
|
|
borderRadius: BorderRadius.circular(10),
|
||
|
|
borderSide: BorderSide(color: widget.accent, width: 1.6),
|
||
|
|
),
|
||
|
|
disabledBorder: OutlineInputBorder(
|
||
|
|
borderRadius: BorderRadius.circular(10),
|
||
|
|
borderSide: BorderSide(color: borderColor, width: 1.2),
|
||
|
|
),
|
||
|
|
),
|
||
|
|
onChanged: (v) => _onChanged(i, v),
|
||
|
|
),
|
||
|
|
),
|
||
|
|
),
|
||
|
|
);
|
||
|
|
}),
|
||
|
|
);
|
||
|
|
}
|
||
|
|
}
|