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) { setState(() { _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 colorScheme = Theme.of(context).colorScheme; final chars = widget.controller.text.split(''); final slotHeight = AppSpacing.xl * 2 + AppSpacing.sm; final slotSpacing = AppSpacing.sm; final isComplete = chars.length == widget.length; return Semantics( label: widget.semanticLabel, child: GestureDetector( onTap: () => _focusNode.requestFocus(), behavior: HitTestBehavior.opaque, child: AnimatedContainer( duration: const Duration(milliseconds: 180), padding: const EdgeInsets.all(AppSpacing.sm), decoration: BoxDecoration( color: colorScheme.surfaceContainerLow, borderRadius: BorderRadius.circular(AppRadius.xl), border: Border.all( color: _isFocused ? colorScheme.primary : colorScheme.outlineVariant, ), boxShadow: _isFocused ? [ BoxShadow( color: colorScheme.secondary.withValues(alpha: 0.28), blurRadius: 18, offset: const Offset(0, 8), ), ] : const [], ), 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, isComplete: isComplete, colorScheme: colorScheme, ), ), if (index != widget.length - 1) SizedBox(width: slotSpacing), ], ], ), ), ], ), ), ), ), ); } Widget _buildCodeCell({ required int index, required List chars, required double slotHeight, required bool isComplete, required ColorScheme colorScheme, }) { 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: hasChar ? colorScheme.surface : colorScheme.surfaceContainerHigh, borderRadius: BorderRadius.circular(AppRadius.md), border: Border.all( color: isActive ? colorScheme.primary : isComplete ? colorScheme.outlineVariant : colorScheme.outline, ), boxShadow: isActive ? [ BoxShadow( color: colorScheme.secondary.withValues(alpha: 0.32), blurRadius: 14, offset: const Offset(0, 6), ), ] : const [], ), child: Text( hasChar ? chars[index] : '', style: TextStyle( fontSize: AppSpacing.xl, fontWeight: FontWeight.w600, color: hasChar ? colorScheme.onSurface : colorScheme.onSurfaceVariant, ), ), ); } }