import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; class OtpCodeInput extends StatefulWidget { final int length; final ValueChanged onChanged; final ValueChanged? 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 createState() => _OtpCodeInputState(); } class _OtpCodeInputState extends State { late final List _controllers; late final List _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), ), ), ), ); }), ); } }