Files
social-app/apps/lib/shared/widgets/fixed_length_code_input.dart
T

231 lines
6.9 KiB
Dart

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<String>? 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<FixedLengthCodeInput> createState() => _FixedLengthCodeInputState();
}
class _FixedLengthCodeInputState extends State<FixedLengthCodeInput> {
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<String> 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,
),
),
);
}
}