Files
App.K12.Student.Miniapp_Flu…/lib/pages/demo/form_demo.dart
T
2026-09-04 15:22:20 +08:00

358 lines
13 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
/// ============================================================
/// 【FormDemoPage】表单校验 Demo(Form + TextFormField + validator)
///
/// 一、和 Vue 表单校验的对照(关键差异):
/// Vue: v-model 双向绑定 + rules 规则对象 + validate()(async-validator)
/// Flutter: TextEditingController 持有值(无 v-model)+
/// 每个 TextFormField 自带 validator 函数 +
/// formKey.currentState.validate() 统一触发
/// validator 是【同步函数】,返回 String?:
/// null → 校验通过;非 null → 错误文本(自动显示在输入框下方红字)
/// —— 所以 Flutter 没有"规则对象",每个字段的规则就写在它自己的
/// validator 里,就近维护,一眼能看到这个字段的所有规则。
///
/// 二、三种校验时机(AutovalidateMode):
/// - disabled(默认) → 只在点提交 validate() 时校验
/// - always → build 时立刻校验(空表单会满屏报错,慎用)
/// - onUserInteraction → 用户碰过该字段后才校验(推荐:
/// 未输入不报错,输错即时提示,提交时再兜底校验一次)
///
/// 三、本页演示的 7 个知识点:
/// 1. 必填/长度校验(用户名 2-8 位)
/// 2. 正则校验(手机号 / 邮箱)
/// 3. 密码强度(必须同时含字母和数字)
/// 4. 跨字段对比(确认密码 vs 密码,validator 里读另一个 controller)
/// 5. FormField<bool> 校验非输入型字段(协议勾选,errorText 手动渲染)
/// 6. 两段式校验:validate() 同步通过后,再模拟异步查重
/// (validator 不能 await,异步业务校验必须放提交后)
/// 7. 防重复提交(_submitting 置灰按钮)+ 重置表单
///
/// 四、高频坑:
/// 1. formState.reset() 只清【校验错误】和 FormField 状态,
/// 不碰 controller.text —— 清空输入要手动 clear();
/// 2. 确认密码的 validator 依赖 _pwdC.text,两个 controller 都必须
/// 在 dispose 里释放,否则内存泄漏;
/// 3. 手机号正则要用 ^...$ 全匹配,不然 11 位之后还能续数字;
/// 4. 加了 .w/.h/.sp 的 widget 不能再用 const(运行时才算尺寸);
/// 5. 异步(await)之后用 context 必须先用 mounted 判空。
/// ============================================================
class FormDemoPage extends StatefulWidget {
const FormDemoPage({super.key});
@override
State<FormDemoPage> createState() => _FormDemoPageState();
}
class _FormDemoPageState extends State<FormDemoPage> {
/// Form 的唯一钥匙:提交(validate)/ 重置(reset)都靠它
final GlobalKey<FormState> _formKey = GlobalKey<FormState>();
/// 每个输入框一个 controller(dispose 里必须释放)
final TextEditingController _nameC = TextEditingController();
final TextEditingController _phoneC = TextEditingController();
final TextEditingController _emailC = TextEditingController();
final TextEditingController _pwdC = TextEditingController();
final TextEditingController _pwd2C = TextEditingController();
bool _showPwd = false; // 密码明文/密文
bool _agreed = false; // 协议是否勾选(FormField 外部镜像,重置用)
bool _submitting = false; // 提交中(防重复提交)
@override
void dispose() {
_nameC.dispose();
_phoneC.dispose();
_emailC.dispose();
_pwdC.dispose();
_pwd2C.dispose();
super.dispose();
}
// ==================== 校验规则(每个字段一个 validator) ====================
/// 用户名:必填 + 2-8 位
String? _validateName(String? v) {
final s = v?.trim() ?? '';
if (s.isEmpty) return '请输入用户名';
if (s.length < 2 || s.length > 8) return '用户名需 2-8 个字符';
return null;
}
/// 手机号:必填 + 正则全匹配(^...$)
String? _validatePhone(String? v) {
final s = v?.trim() ?? '';
if (s.isEmpty) return '请输入手机号';
if (!RegExp(r'^1[3-9]\d{9}$').hasMatch(s)) return '手机号格式不正确';
return null;
}
/// 邮箱:选填,填了才校验格式
String? _validateEmail(String? v) {
final s = v?.trim() ?? '';
if (s.isEmpty) return null;
if (!RegExp(r'^[\w.+-]+@[\w-]+\.[\w.-]+$').hasMatch(s)) return '邮箱格式不正确';
return null;
}
/// 密码:必填 + 6-20 位 + 同时含字母和数字
String? _validatePwd(String? v) {
final s = v ?? '';
if (s.isEmpty) return '请输入密码';
if (s.length < 6 || s.length > 20) return '密码需 6-20 位';
if (!RegExp(r'(?=.*[A-Za-z])(?=.*\d)').hasMatch(s)) return '密码需同时包含字母和数字';
return null;
}
/// 确认密码:必填 + 跨字段对比(读 _pwdC.text)
String? _validatePwd2(String? v) {
final s = v ?? '';
if (s.isEmpty) return '请再次输入密码';
if (s != _pwdC.text) return '两次密码不一致';
return null;
}
// ==================== 提交 / 重置 ====================
/// 两段式校验:先同步 validate(),通过后模拟异步查重
Future<void> _submit() async {
if (_submitting) return; // 防连点
FocusScope.of(context).unfocus(); // 收起键盘
// ① 同步校验:所有子字段的 validator 逐个执行,只要有一个返回
// 非 null → validate() 为 false,错误红字已自动显示。
if (!_formKey.currentState!.validate()) {
_toast('有字段未通过校验,请检查红字提示');
return;
}
// ② 同步通过 → 异步业务校验(查重/验证码)。validator 是同步函数
// 不能 await,异步校验必须在 validate() 之后单独做。
setState(() => _submitting = true);
await Future.delayed(const Duration(seconds: 1)); // 模拟查重请求
if (!mounted) return; // 异步后必须判 mounted 才能用 context
setState(() => _submitting = false);
_toast('校验全部通过,注册成功!');
}
/// 重置:formState.reset() 只清校验错误,输入内容要手动 clear()
void _reset() {
_formKey.currentState?.reset();
_nameC.clear();
_phoneC.clear();
_emailC.clear();
_pwdC.clear();
_pwd2C.clear();
setState(() => _agreed = false);
}
void _toast(String msg) {
ScaffoldMessenger.of(context)
..hideCurrentSnackBar()
..showSnackBar(
SnackBar(content: Text(msg), duration: const Duration(seconds: 2)),
);
}
// ==================== UI ====================
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('表单校验 Demo')),
body: SingleChildScrollView(
padding: EdgeInsets.all(20.w),
child: Form(
key: _formKey,
// onUserInteraction:用户碰过的字段才即时校验,未碰不报错
autovalidateMode: AutovalidateMode.onUserInteraction,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_buildTipCard(),
SizedBox(height: 16.h),
_buildField(
label: '用户名',
hint: '2-8 个字符',
icon: Icons.person_outline,
controller: _nameC,
validator: _validateName,
),
SizedBox(height: 16.h),
_buildField(
label: '手机号',
hint: '11 位大陆手机号',
icon: Icons.phone_iphone,
controller: _phoneC,
validator: _validatePhone,
keyboardType: TextInputType.phone,
),
SizedBox(height: 16.h),
_buildField(
label: '邮箱(选填)',
hint: '例:name@example.com',
icon: Icons.mail_outline,
controller: _emailC,
validator: _validateEmail,
keyboardType: TextInputType.emailAddress,
),
SizedBox(height: 16.h),
// 密码:带"眼睛"显隐切换
TextFormField(
controller: _pwdC,
obscureText: !_showPwd,
validator: _validatePwd,
decoration: InputDecoration(
labelText: '密码',
hintText: '6-20 位,含字母和数字',
prefixIcon: const Icon(Icons.lock_outline),
suffixIcon: IconButton(
icon: Icon(
_showPwd ? Icons.visibility_outlined : Icons.visibility_off_outlined,
),
onPressed: () => setState(() => _showPwd = !_showPwd),
),
border: const OutlineInputBorder(),
),
),
SizedBox(height: 16.h),
_buildField(
label: '确认密码',
hint: '再次输入密码',
icon: Icons.lock_outline,
controller: _pwd2C,
validator: _validatePwd2,
),
SizedBox(height: 16.h),
_buildAgreement(),
SizedBox(height: 24.h),
_buildButtons(),
],
),
),
),
);
}
/// 顶部说明卡片
Widget _buildTipCard() {
return Container(
padding: EdgeInsets.all(16.w),
decoration: BoxDecoration(
color: const Color(0xFFE8F0FE),
borderRadius: BorderRadius.circular(12.r),
),
child: const Text(
'校验机制:Form 收集所有子字段的 validator,'
'点提交统一 validate()。validator 返回 null=通过,'
'返回字符串=错误红字(自动显示)。autovalidateMode 控制即时校验。',
style: TextStyle(fontSize: 13, color: Color(0xFF3B82F6)),
),
);
}
/// 通用输入框(label/hint/图标/校验规则全部参数化)
Widget _buildField({
required String label,
required String hint,
required IconData icon,
required TextEditingController controller,
required String? Function(String?) validator,
TextInputType? keyboardType,
}) {
return TextFormField(
controller: controller,
keyboardType: keyboardType,
validator: validator,
decoration: InputDecoration(
labelText: label,
hintText: hint,
prefixIcon: Icon(icon),
border: const OutlineInputBorder(),
),
);
}
/// 协议勾选:FormField<bool> 校验"非输入型"字段。
/// 注意:自定义 FormField 的 errorText 要自己在 builder 里渲染。
Widget _buildAgreement() {
return FormField<bool>(
initialValue: _agreed,
validator: (v) => (v ?? false) ? null : '请先勾选同意协议',
builder: (field) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
InkWell(
onTap: () {
field.didChange(!(field.value ?? false)); // 更新 FormField 自身
setState(() => _agreed = field.value ?? false); // 同步外部镜像
},
child: Row(
children: [
Icon(
_agreed
? Icons.check_circle_rounded
: Icons.radio_button_unchecked,
size: 24,
color: _agreed
? const Color(0xFF3B82F6)
: const Color(0xFFC0C4CC),
),
SizedBox(width: 8.w),
const Text('我已阅读并同意《用户协议》和《隐私政策》'),
],
),
),
if (field.hasError)
Padding(
padding: EdgeInsets.only(top: 4.h),
child: Text(
field.errorText!,
style: const TextStyle(color: Colors.red, fontSize: 12),
),
),
],
);
},
);
}
/// 提交 + 重置按钮
Widget _buildButtons() {
return Row(
children: [
Expanded(
child: SizedBox(
height: 48.h,
child: FilledButton(
// 提交中禁用:防重复提交
onPressed: _submitting ? null : _submit,
child: _submitting
? const SizedBox(
width: 22,
height: 22,
child: CircularProgressIndicator(
strokeWidth: 2.5,
color: Colors.white,
),
)
: const Text('提交'),
),
),
),
SizedBox(width: 12.w),
Expanded(
child: SizedBox(
height: 48.h,
child: OutlinedButton(
onPressed: _submitting ? null : _reset,
child: const Text('重置'),
),
),
),
],
);
}
}