gcx/frontend/genex-mobile/lib/shared/widgets/otp_input.dart

161 lines
4.7 KiB
Dart

import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../../app/theme/app_colors.dart';
import '../../app/theme/app_spacing.dart';
/// 6 位 OTP 验证码输入组件
///
/// 特性: 自动跳转、支持粘贴、错误状态、自动聚焦
class OtpInput extends StatefulWidget {
final int length;
final ValueChanged<String> onCompleted;
final ValueChanged<String>? onChanged;
final bool hasError;
final bool autofocus;
const OtpInput({
super.key,
this.length = 6,
required this.onCompleted,
this.onChanged,
this.hasError = false,
this.autofocus = true,
});
@override
State<OtpInput> createState() => _OtpInputState();
}
class _OtpInputState extends State<OtpInput> {
late List<TextEditingController> _controllers;
late List<FocusNode> _focusNodes;
@override
void initState() {
super.initState();
_controllers = List.generate(widget.length, (_) => TextEditingController());
_focusNodes = List.generate(widget.length, (_) => FocusNode());
}
@override
void dispose() {
for (final c in _controllers) {
c.dispose();
}
for (final n in _focusNodes) {
n.dispose();
}
super.dispose();
}
String get _code => _controllers.map((c) => c.text).join();
void _onChanged(int index, String value) {
// 粘贴完整验证码
if (value.length > 1) {
final digits = value.replaceAll(RegExp(r'[^\d]'), '');
for (int i = 0; i < widget.length && i < digits.length; i++) {
_controllers[i].text = digits[i];
}
final focusIdx = digits.length.clamp(0, widget.length - 1);
_focusNodes[focusIdx].requestFocus();
_notifyChange();
return;
}
if (value.isNotEmpty && index < widget.length - 1) {
_focusNodes[index + 1].requestFocus();
}
_notifyChange();
}
void _onKeyEvent(int index, KeyEvent event) {
if (event is KeyDownEvent &&
event.logicalKey == LogicalKeyboardKey.backspace &&
_controllers[index].text.isEmpty &&
index > 0) {
_controllers[index - 1].clear();
_focusNodes[index - 1].requestFocus();
_notifyChange();
}
}
void _notifyChange() {
final code = _code;
widget.onChanged?.call(code);
if (code.length == widget.length) {
widget.onCompleted(code);
}
}
void clear() {
for (final c in _controllers) {
c.clear();
}
_focusNodes[0].requestFocus();
}
@override
Widget build(BuildContext context) {
return Row(
mainAxisAlignment: MainAxisAlignment.center,
children: List.generate(widget.length, (i) {
final isActive = _focusNodes[i].hasFocus;
final hasFilled = _controllers[i].text.isNotEmpty;
return Container(
width: 48,
height: 56,
margin: EdgeInsets.symmetric(horizontal: i == 0 || i == widget.length - 1 ? 0 : 4),
child: KeyboardListener(
focusNode: FocusNode(),
onKeyEvent: (event) => _onKeyEvent(i, event),
child: TextField(
controller: _controllers[i],
focusNode: _focusNodes[i],
autofocus: widget.autofocus && i == 0,
textAlign: TextAlign.center,
keyboardType: TextInputType.number,
maxLength: 1,
style: const TextStyle(
fontSize: 24,
fontWeight: FontWeight.w700,
color: AppColors.textPrimary,
),
inputFormatters: [
FilteringTextInputFormatter.digitsOnly,
LengthLimitingTextInputFormatter(1),
],
decoration: InputDecoration(
counterText: '',
contentPadding: EdgeInsets.zero,
filled: true,
fillColor: widget.hasError
? AppColors.errorLight
: hasFilled
? AppColors.primarySurface
: AppColors.gray50,
enabledBorder: OutlineInputBorder(
borderRadius: AppSpacing.borderRadiusMd,
borderSide: BorderSide(
color: widget.hasError ? AppColors.error : AppColors.border,
width: 1.5,
),
),
focusedBorder: OutlineInputBorder(
borderRadius: AppSpacing.borderRadiusMd,
borderSide: BorderSide(
color: widget.hasError ? AppColors.error : AppColors.primary,
width: 2,
),
),
),
onChanged: (v) => _onChanged(i, v),
),
),
);
}),
);
}
}