flutter架 构项目基础

This commit is contained in:
崔宝新
2026-09-04 15:22:20 +08:00
parent 70967229d0
commit 0c5aee59c0
265 changed files with 35484 additions and 0 deletions
+357
View File
@@ -0,0 +1,357 @@
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('重置'),
),
),
),
],
);
}
}