Files
social-app/apps/lib/shared/widgets/fixed_length_code_input.dart
T
qzl a10a2db27a feat: 添加视觉设计语言系统并重构认证页面UI
- 新增 visual_design_language.md 设计规范文档
- 新增 auth 设计 tokens (authBackground, authCard, authInput, feedback 系列等)
- 重构登录/注册/验证码/重置密码页面为新设计系统
- 新增 AuthHeroHeader, AuthSurfaceCard, AuthSection, AuthField, PasswordField 组件
- 重构 AppBanner 和 Toast 支持多类型配置 (info/success/warning/error)
- 后端 AgentScope: 重整 schemas/prompts/tools 作用域, 新增协议文档
- 更新 AGENTS.md 集成视觉设计语言约束
2026-03-13 14:10:13 +08:00

228 lines
6.8 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 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: AppColors.authSectionBackground,
borderRadius: BorderRadius.circular(AppRadius.xl),
border: Border.all(
color: _isFocused
? AppColors.authInputFocus
: AppColors.authSectionBorder,
),
boxShadow: _isFocused
? [
BoxShadow(
color: AppColors.blue200.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,
),
),
if (index != widget.length - 1)
SizedBox(width: slotSpacing),
],
],
),
),
],
),
),
),
),
);
}
Widget _buildCodeCell({
required int index,
required List<String> chars,
required double slotHeight,
required bool isComplete,
}) {
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 ? AppColors.white : AppColors.authInputBackground,
borderRadius: BorderRadius.circular(AppRadius.md),
border: Border.all(
color: isActive
? AppColors.authPrimaryButton
: isComplete
? AppColors.authSecondaryButtonBorder
: AppColors.authInputBorder,
),
boxShadow: isActive
? [
BoxShadow(
color: AppColors.blue200.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 ? AppColors.slate900 : AppColors.authLinkMuted,
),
),
);
}
}