import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import '../../core/theme/design_tokens.dart'; typedef CodeValueChanged = void Function(String value); class FixedLengthCodeInput extends StatefulWidget { final TextEditingController controller; final int length; final CodeValueChanged? onChanged; final TextInputType keyboardType; final Iterable? allowedCharacters; final bool uppercase; final String semanticLabel; const FixedLengthCodeInput({ required this.controller, required this.length, required this.semanticLabel, super.key, this.onChanged, this.keyboardType = TextInputType.text, this.allowedCharacters, this.uppercase = false, }); @override State createState() => _FixedLengthCodeInputState(); } class _FixedLengthCodeInputState extends State { late final FocusNode _focusNode; bool _isFocused = false; @override void initState() { super.initState(); _focusNode = FocusNode(); _focusNode.addListener(_onFocusChanged); widget.controller.addListener(_onControllerChanged); } @override void didUpdateWidget(covariant FixedLengthCodeInput oldWidget) { super.didUpdateWidget(oldWidget); if (oldWidget.controller != widget.controller) { oldWidget.controller.removeListener(_onControllerChanged); widget.controller.addListener(_onControllerChanged); } } @override void dispose() { widget.controller.removeListener(_onControllerChanged); _focusNode.removeListener(_onFocusChanged); _focusNode.dispose(); super.dispose(); } void _onFocusChanged() { if (_isFocused != _focusNode.hasFocus) { _isFocused = _focusNode.hasFocus; } } void _onControllerChanged() { if (mounted) { setState(() {}); } } void _handleRawChanged(String rawValue) { final normalized = _normalize(rawValue); if (normalized != widget.controller.text) { widget.controller.value = TextEditingValue( text: normalized, selection: TextSelection.collapsed(offset: normalized.length), ); } widget.onChanged?.call(normalized); } String _normalize(String value) { var output = widget.uppercase ? value.toUpperCase() : value; if (widget.allowedCharacters != null) { final allow = widget.allowedCharacters!.toSet(); output = output.split('').where(allow.contains).join(); } if (output.length > widget.length) { output = output.substring(0, widget.length); } return output; } @override Widget build(BuildContext context) { final chars = widget.controller.text.split(''); final slotHeight = AppSpacing.xl * 2; final slotSpacing = AppSpacing.sm; return Semantics( label: widget.semanticLabel, child: GestureDetector( onTap: () => _focusNode.requestFocus(), behavior: HitTestBehavior.opaque, child: SizedBox( height: slotHeight, child: Stack( alignment: Alignment.center, children: [ Opacity( opacity: 0, child: SizedBox( width: double.infinity, height: slotHeight, child: TextField( controller: widget.controller, focusNode: _focusNode, keyboardType: widget.keyboardType, inputFormatters: [ LengthLimitingTextInputFormatter(widget.length), ], onChanged: _handleRawChanged, autofillHints: const [AutofillHints.oneTimeCode], ), ), ), IgnorePointer( child: Row( mainAxisSize: MainAxisSize.max, crossAxisAlignment: CrossAxisAlignment.center, children: [ for (var index = 0; index < widget.length; index++) ...[ Expanded( child: _buildCodeCell( index: index, chars: chars, slotHeight: slotHeight, ), ), if (index != widget.length - 1) SizedBox(width: slotSpacing), ], ], ), ), ], ), ), ), ); } Widget _buildCodeCell({ required int index, required List chars, required double slotHeight, }) { final hasChar = index < chars.length; final isActive = (chars.length == index && _isFocused) || (chars.length >= widget.length && index == widget.length - 1); return Container( height: slotHeight, alignment: Alignment.center, decoration: BoxDecoration( color: AppColors.white, borderRadius: BorderRadius.circular(AppRadius.sm), border: Border.all( color: isActive ? AppColors.primary : AppColors.slate300, ), ), child: Text( hasChar ? chars[index] : '', style: const TextStyle( fontSize: AppSpacing.xl, fontWeight: FontWeight.w600, color: AppColors.slate900, ), ), ); } }