refactor-form-to-builder

将传统表单页面重构为使用 FormBuilderWrapper 的声明式表单。自动检测表单字段,检查是否有对应的封装 field 组件,如果没有则先创建 field 包装器,然后将表单转换为 FormFieldConfig 列表形式,减少重复代码

$ インストール

git clone https://github.com/hunmer/Memento /tmp/Memento && cp -r /tmp/Memento/.claude/skills/refactor-form-to-builder ~/.claude/skills/Memento

// tip: Run this command in your terminal to install the skill


name: refactor-form-to-builder description: 将传统表单页面重构为使用 FormBuilderWrapper 的声明式表单。自动检测表单字段,检查是否有对应的封装 field 组件,如果没有则先创建 field 包装器,然后将表单转换为 FormFieldConfig 列表形式,减少重复代码

Refactor Form to FormBuilderWrapper

将传统表单页面重构为使用 FormBuilderWrapper 的声明式表单系统,减少重复代码并提高可维护性。

⚠️ 重要:提交按钮的最佳实践

强烈建议将提交按钮放在 FormBuilderWrapper 内部,使用 buttonBuilder 或直接设置 showSubmitButton: true

如果因为 UI 需求必须将按钮放在外部(如 AppBar),必须按以下方式操作:

// ✅ 正确做法
class MyFormScreen extends StatefulWidget {
  @override
  State<MyFormScreen> createState() => _MyFormScreenState();
}

class _MyFormScreenState extends State<MyFormScreen> {
  final GlobalKey<FormBuilderState> _formKey = GlobalKey<FormBuilderState>();
  FormBuilderWrapperState? _wrapperState;  // 关键:存储 wrapper 状态

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        actions: [
          TextButton(
            onPressed: _handleSave,  // 使用这个方法
            child: Text('保存'),
          ),
        ],
      ),
      body: FormBuilderWrapper(
        formKey: _formKey,
        onStateReady: (state) => _wrapperState = state,  // 关键:获取状态
        config: FormConfig(
          fields: [...],
          onSubmit: _handleSubmit,  // 表单值在这里处理
        ),
      ),
    );
  }

  void _handleSave() {
    // 验证逻辑...
    // 使用 wrapperState 提交表单
    _wrapperState?.submitForm();  // 关键:调用 submitForm
  }

  void _handleSubmit(Map<String, dynamic> values) {
    // 处理表单数据
  }
}
// ❌ 错误做法:直接使用 _formKey.currentState?.value
void _handleSave() {
  final values = _formKey.currentState?.value ?? {};  // WrappedFormField 值不会被收集!
  _handleSubmit(values);
}

Usage

# 基础用法 - 自动检测表单并转换
/refactor-form-to-builder <form-file>

# 指定表单所在的 widget 方法
/refactor-form-to-builder <form-file> --method _buildFormSection

# 只检测不修改(预览模式)
/refactor-form-to-builder <form-file> --dry-run

Examples:

# 重构用户设置表单
/refactor-form-to-builder lib/screens/settings/settings_screen.dart

# 重构插件配置表单
/refactor-form-to-builder lib/plugins/goods/views/goods_edit_view.dart --method _buildConfigForm

# 预览将要做的修改
/refactor-form-to-builder lib/screens/profile/edit_profile_screen.dart --dry-run

Arguments

  • <form-file>: 目标表单文件路径
  • --method <name>: 表单所在的构建方法名(默认:自动检测 build_buildForm 方法)
  • --dry-run: 只分析不修改,显示预览

Workflow

1. Analyze Target File

读取目标文件并识别:

  • 表单结构: StatefulWidget/FormBuilder
  • 字段类型: TextField, DropdownButton, Checkbox, Switch 等
  • 状态变量: _controller, _value, _selectedXxx
  • 验证逻辑: validator 函数
  • 提交逻辑: onPressed/onSubmitted 回调

2. Detect Form Fields

识别常见表单字段的模式:

文本输入类

// 检测模式:
TextField(
  controller: _nameController,
  decoration: InputDecoration(labelText: '名称'),
)

TextFormField(
  controller: _emailController,
  validator: (value) => value?.isEmpty ? '必填' : null,
)

→ 转换为 FormFieldType.text

选择器类

// 检测模式:
DropdownButton<String>(
  value: _selectedType,
  items: [DropdownMenuItem(...)],
  onChanged: (value) => setState(() => _selectedType = value),
)

showDatePicker(context: context, ...)

→ 转换为 FormFieldType.selectFormFieldType.date

开关/滑块类

// 检测模式:
Switch(
  value: _isEnabled,
  onChanged: (value) => setState(() => _isEnabled = value),
)

Slider(
  value: _sliderValue,
  onChanged: (value) => setState(() => _sliderValue = value),
)

→ 转换为 FormFieldType.switchFieldFormFieldType.slider

Picker 选择器类

// 检测模式:
showDialog(
  context: context,
  builder: (_) => IconPickerDialog(...),
)

showDialog(
  context: context,
  builder: (_) => ImagePickerDialog(...),
)

LocationPicker(...)
AvatarPicker(...)

→ 检测是否有对应的 field 文件,如果没有则创建

3. Check/Create Field Wrappers

对于每个检测到的 picker 类型,检查是否已有对应的 field 文件:

// 检查文件是否存在:
lib/widgets/form_fields/<type>_field.dart

如果不存在,创建 field 包装器:

// lib/widgets/form_fields/my_picker_field.dart
import 'package:flutter/material.dart';
import '../picker/my_picker_dialog.dart';

class MyPickerField extends StatelessWidget {
  final dynamic currentValue;
  final String? labelText;
  final bool enabled;
  final ValueChanged<dynamic> onValueChanged;

  const MyPickerField({
    super.key,
    required this.currentValue,
    required this.onValueChanged,
    this.labelText,
    this.enabled = true,
  });

  @override
  Widget build(BuildContext context) {
    return GestureDetector(
      onTap: enabled ? () async {
        final result = await showDialog<dynamic>(
          context: context,
          builder: (context) => MyPickerDialog(
            initialValue: currentValue,
          ),
        );
        if (result != null) {
          onValueChanged(result);
        }
      } : null,
      child: Container(
        padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
        decoration: BoxDecoration(
          border: Border.all(color: Theme.of(context).colorScheme.outline),
          borderRadius: BorderRadius.circular(8),
        ),
        child: Row(
          children: [
            Icon(Icons.my_icon, size: 24),
            const SizedBox(width: 12),
            Expanded(
              child: Text(
                _getDisplayText(),
                style: Theme.of(context).textTheme.bodyMedium,
              ),
            ),
            if (enabled) const Icon(Icons.arrow_drop_down),
          ],
        ),
      ),
    );
  }

  String _getDisplayText() {
    if (currentValue != null) {
      return currentValue.toString();
    }
    return labelText ?? '选择';
  }
}

然后:

  1. index.dart 中添加导出
  2. form_builder_wrapper.dartFormFieldType 枚举中添加类型
  3. form_builder_wrapper.dart_buildField 方法中添加 case
  4. form_builder_wrapper.dart 中添加 _buildXxxField 方法

4. Extract Form Configuration

将检测到的字段转换为 FormFieldConfig 列表:

// 原始代码:
TextField(
  controller: _nameController,
  decoration: InputDecoration(labelText: '名称'),
  prefixIcon: Icon(Icons.person),
)

// 转换为:
FormFieldConfig(
  name: 'name',
  type: FormFieldType.text,
  labelText: '名称',
  prefixIcon: Icons.person,
  initialValue: _nameController.text,
)

5. Create FormBuilderWrapper

FormBuilderWrapper 替换原表单:

// 原始表单:
Column(
  children: [
    TextField(controller: _nameController, ...),
    TextField(controller: _emailController, ...),
    DropdownButton(...),
    Switch(...),
    ElevatedButton(
      onPressed: _submit,
      child: Text('提交'),
    ),
  ],
)

// 转换为:
FormBuilderWrapper(
  config: FormConfig(
    fields: [
      FormFieldConfig(name: 'name', type: FormFieldType.text, ...),
      FormFieldConfig(name: 'email', type: FormFieldType.email, ...),
      FormFieldConfig(name: 'type', type: FormFieldType.select, ...),
      FormFieldConfig(name: 'enabled', type: FormFieldType.switchField, ...),
    ],
    submitButtonText: '提交',
    showResetButton: true,
    onSubmit: (values) => _handleSubmit(values),
  ),
)

6. Update Submit Handler

修改提交处理函数:

// 原始代码:
void _submit() {
  final name = _nameController.text;
  final email = _emailController.text;
  final type = _selectedType;
  final enabled = _isEnabled;

  _saveData(name: name, email: email, type: type, enabled: enabled);
}

// 转换为:
void _handleSubmit(Map<String, dynamic> values) {
  final name = values['name'] as String;
  final email = values['email'] as String;
  final type = values['type'] as String;
  final enabled = values['enabled'] as bool;

  _saveData(name: name, email: email, type: type, enabled: enabled);
}

7. Remove Old State Variables

删除不再需要的状态变量和控制器:

// 删除这些:
TextEditingController _nameController = TextEditingController();
TextEditingController _emailController = TextEditingController();
String? _selectedType;
bool _isEnabled = true;

// 在 dispose 中删除:
_nameController.dispose();
_emailController.dispose();

FormFieldType Mapping

原始组件FormFieldType配置示例
TextFieldtext基础文本输入
TextFormFieldtext带验证的文本
TextField(obscureText: true)password密码输入
TextField(keyboardType: email)email邮箱输入
TextField(keyboardType: number)number数字输入
TextField(maxLines: >1)textArea多行文本
DropdownButtonselect下拉选择
showDatePickerdate日期选择
showTimePickertime时间选择
SwitchswitchField开关
Sliderslider滑块
Checkbox可用 switchField 或自定义复选框
IconPickerDialogiconPicker图标选择器
AvatarPickeravatarPicker头像选择器
CircleIconPickercircleIconPicker圆形图标选择器
CalendarStripDatePickercalendarStripPicker日历条选择器
ImagePickerDialogimagePicker图片选择器
LocationPickerlocationPicker位置选择器
ColorPickerSectioncolor颜色选择器
PromptEditorpromptEditor提示词编辑器(复合字段)
IconAvatarRowiconAvatarRow图标头像并排(复合字段)

高级功能

1. 条件显示字段

使用 visible 参数根据其他字段的值动态显示/隐藏字段:

// 开场白问题列表只在启用时显示
FormFieldConfig(
  name: 'openingQuestions',
  type: FormFieldType.listAdd,
  initialValue: [],
  visible: (values) => values['enableOpeningQuestions'] == true,
),

// 高级选项只在选中"自定义"时显示
FormFieldConfig(
  name: 'customConfig',
  type: FormFieldType.textArea,
  visible: (values) => values['mode'] == 'custom',
),

2. 输入框组按钮

使用 prefixButtonssuffixButtons 在文本输入框前后添加操作按钮:

FormFieldConfig(
  name: 'model',
  type: FormFieldType.text,
  labelText: '模型',
  hintText: '输入或选择模型',
  suffixButtons: [
    InputGroupButton(
      icon: Icons.search,
      tooltip: '搜索模型',
      onPressed: () {
        // 打开模型选择对话框
        _selectModel();
      },
    ),
  ],
),

// 带前后缀按钮的输入框
FormFieldConfig(
  name: 'apiKey',
  type: FormFieldType.text,
  prefixButtons: [
    InputGroupButton(
      icon: Icons.key,
      tooltip: '生成密钥',
      onPressed: () => _generateApiKey(),
    ),
  ],
  suffixButtons: [
    InputGroupButton(
      icon: Icons.visibility,
      tooltip: '显示/隐藏',
      onPressed: () => _toggleVisibility(),
    ),
  ],
),

3. 字段联动与回调

使用 onChanged 回调实现字段间的联动:

// 服务商选择后自动更新配置
FormFieldConfig(
  name: 'serviceProvider',
  type: FormFieldType.select,
  labelText: '服务商',
  items: _providers.map((p) => DropdownMenuItem(
    value: p.id,
    child: Text(p.label),
  )).toList(),
  onChanged: (value) {
    // 服务商切换时,自动更新相关字段
    if (value != null) {
      final provider = _providers.firstWhere((p) => p.id == value);
      _updateProviderFields(provider);
    }
  },
),

// 数量变化时重新计算价格
FormFieldConfig(
  name: 'quantity',
  type: FormFieldType.number,
  labelText: '数量',
  onChanged: (value) {
    // 数量变化时自动更新价格
    _updatePrice();
  },
),

4. 完整的联动表单示例

FormBuilderWrapper(
  config: FormConfig(
    fields: [
      // 启用开场白问题开关
      FormFieldConfig(
        name: 'enableOpeningQuestions',
        type: FormFieldType.switchField,
        labelText: '启用开场白问题',
        initialValue: false,
      ),

      // 开场白问题列表 - 条件显示
      FormFieldConfig(
        name: 'openingQuestions',
        type: FormFieldType.listAdd,
        initialValue: [],
        visible: (values) => values['enableOpeningQuestions'] == true,
      ),

      // 服务商选择 - 联动更新配置
      FormFieldConfig(
        name: 'serviceProvider',
        type: FormFieldType.select,
        labelText: '服务商',
        initialValue: _providers.first.id,
        items: _providers.map((p) => DropdownMenuItem(
          value: p.id,
          child: Text(p.label),
        )).toList(),
        onChanged: (value) {
          if (value != null) {
            final provider = _providers.firstWhere((p) => p.id == value);
            // 自动更新 BaseUrl 和 Headers
            _formKey.currentState?.patchValue({
              'baseUrl': provider.baseUrl,
              'headers': _formatHeaders(provider.headers),
            });
          }
        },
      ),

      // 模型选择 - 带搜索按钮
      FormFieldConfig(
        name: 'model',
        type: FormFieldType.text,
        labelText: '模型',
        suffixButtons: [
          InputGroupButton(
            icon: Icons.search,
            tooltip: '搜索模型',
            onPressed: () => _selectModel(),
          ),
        ],
      ),
    ],
    onSubmit: (values) {
      // values 包含所有字段的最新值
      _saveAgent(values);
    },
  ),
)

5. InputGroupButton 类型定义

class InputGroupButton {
  /// 按钮图标
  final IconData icon;

  /// 按钮提示文本
  final String? tooltip;

  /// 点击回调
  final VoidCallback onPressed;

  const InputGroupButton({
    required this.icon,
    this.tooltip,
    required this.onPressed,
  });
}

Extra Parameters Guide

对于 picker 类型字段,使用 extra 参数传递配置:

FormFieldConfig(
  name: 'avatar',
  type: FormFieldType.avatarPicker,
  extra: {
    'username': 'User',           // 用户名
    'size': 80.0,                 // 头像大小
    'saveDirectory': 'avatars',   // 保存目录
  },
),

FormFieldConfig(
  name: 'image',
  type: FormFieldType.imagePicker,
  extra: {
    'enableCrop': true,           // 启用裁剪
    'cropAspectRatio': 1.0,       // 裁剪比例
    'multiple': true,             // 多选
    'enableCompression': false,   // 启用压缩
    'saveDirectory': 'images',    // 保存目录
  },
),

FormFieldConfig(
  name: 'date',
  type: FormFieldType.calendarStripPicker,
  extra: {
    'allowFutureDates': false,    // 是否允许未来日期
    'useShortWeekDay': true,      // 使用短周名
  },
),

Detection Patterns

TextField

查找模式:

TextField(
  controller: _xxxController,
  decoration: InputDecoration(
    labelText: 'xxx',
    hintText: 'xxx',
    prefixIcon: Icon(xxx),
  ),
)

DropdownButton

查找模式:

DropdownButton<T>(
  value: _selectedXxx,
  items: [
    DropdownMenuItem(value: x1, child: Text('x1')),
    DropdownMenuItem(value: x2, child: Text('x2')),
  ],
  onChanged: (value) => setState(() => _selectedXxx = value),
)

Switch

查找模式:

Switch(
  value: _xxxEnabled,
  onChanged: (value) => setState(() => _xxxEnabled = value),
)

Slider

查找模式:

Slider(
  value: _xxxValue,
  min: 0,
  max: 100,
  divisions: 10,
  onChanged: (value) => setState(() => _xxxValue = value),
)

Picker Dialogs

查找模式:

showDialog(
  context: context,
  builder: (_) => XxxPickerDialog(
    currentValue: _currentValue,
    onChanged: (value) => setState(() => _currentValue = value),
  ),
)

Example: Complete Conversion

Before (Traditional Form)

class EditUserScreen extends StatefulWidget {
  @override
  _EditUserScreenState createState() => _EditUserScreenState();
}

class _EditUserScreenState extends State<EditUserScreen> {
  final _nameController = TextEditingController();
  final _emailController = TextEditingController();
  String? _selectedRole;
  bool _isActive = true;
  IconData? _selectedIcon;
  String? _avatarPath;

  @override
  void dispose() {
    _nameController.dispose();
    _emailController.dispose();
    super.dispose();
  }

  void _submit() {
    if (_nameController.text.isEmpty) {
      ScaffoldMessenger.of(context).showSnackBar(
        SnackBar(content: Text('姓名不能为空')),
      );
      return;
    }

    final user = User(
      name: _nameController.text,
      email: _emailController.text,
      role: _selectedRole ?? 'user',
      isActive: _isActive,
      icon: _selectedIcon,
      avatar: _avatarPath,
    );

    Navigator.pop(context, user);
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('编辑用户')),
      body: SingleChildScrollView(
        padding: EdgeInsets.all(16),
        child: Column(
          children: [
            TextField(
              controller: _nameController,
              decoration: InputDecoration(
                labelText: '姓名',
                prefixIcon: Icon(Icons.person),
              ),
            ),
            SizedBox(height: 16),
            TextField(
              controller: _emailController,
              decoration: InputDecoration(
                labelText: '邮箱',
                prefixIcon: Icon(Icons.email),
              ),
            ),
            SizedBox(height: 16),
            DropdownButton<String>(
              value: _selectedRole,
              hint: Text('选择角色'),
              items: [
                DropdownMenuItem(value: 'admin', child: Text('管理员')),
                DropdownMenuItem(value: 'user', child: Text('用户')),
                DropdownMenuItem(value: 'guest', child: Text('访客')),
              ],
              onChanged: (value) => setState(() => _selectedRole = value),
            ),
            SizedBox(height: 16),
            SwitchListTile(
              title: Text('启用状态'),
              value: _isActive,
              onChanged: (value) => setState(() => _isActive = value),
            ),
            SizedBox(height: 16),
            ListTile(
              title: Text('图标'),
              trailing: Icon(_selectedIcon ?? Icons.help),
              onTap: () async {
                final result = await showDialog<IconData>(
                  context: context,
                  builder: (_) => IconPickerDialog(currentIcon: _selectedIcon),
                );
                if (result != null) {
                  setState(() => _selectedIcon = result);
                }
              },
            ),
            SizedBox(height: 16),
            ListTile(
              title: Text('头像'),
              leading: CircleAvatar(),
              onTap: () async {
                final result = await showDialog<String>(
                  context: context,
                  builder: (_) => AvatarPickerDialog(
                    username: _nameController.text,
                    currentAvatarPath: _avatarPath,
                  ),
                );
                if (result != null) {
                  setState(() => _avatarPath = result);
                }
              },
            ),
            SizedBox(height: 24),
            ElevatedButton(
              onPressed: _submit,
              child: Text('保存'),
            ),
          ],
        ),
      ),
    );
  }
}

After (FormBuilderWrapper)

class EditUserScreen extends StatelessWidget {
  final User? initialUser;

  const EditUserScreen({super.key, this.initialUser});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('编辑用户')),
      body: SingleChildScrollView(
        padding: EdgeInsets.all(16),
        child: FormBuilderWrapper(
          config: FormConfig(
            fields: [
              // 姓名
              FormFieldConfig(
                name: 'name',
                type: FormFieldType.text,
                labelText: '姓名',
                hintText: '请输入姓名',
                initialValue: initialUser?.name ?? '',
                prefixIcon: Icons.person,
                required: true,
                validationMessage: '姓名不能为空',
              ),

              // 邮箱
              FormFieldConfig(
                name: 'email',
                type: FormFieldType.email,
                labelText: '邮箱',
                hintText: '请输入邮箱地址',
                initialValue: initialUser?.email ?? '',
                prefixIcon: Icons.email,
              ),

              // 角色
              FormFieldConfig(
                name: 'role',
                type: FormFieldType.select,
                labelText: '角色',
                hintText: '请选择角色',
                initialValue: initialUser?.role ?? 'user',
                required: true,
                items: const [
                  DropdownMenuItem(value: 'admin', child: Text('管理员')),
                  DropdownMenuItem(value: 'user', child: Text('用户')),
                  DropdownMenuItem(value: 'guest', child: Text('访客')),
                ],
              ),

              // 启用状态
              FormFieldConfig(
                name: 'isActive',
                type: FormFieldType.switchField,
                labelText: '启用状态',
                hintText: '是否启用此用户',
                initialValue: initialUser?.isActive ?? true,
                prefixIcon: Icons.power_settings_new,
              ),

              // 图标
              FormFieldConfig(
                name: 'icon',
                type: FormFieldType.iconPicker,
                labelText: '选择图标',
                initialValue: initialUser?.icon ?? Icons.person,
              ),

              // 头像
              FormFieldConfig(
                name: 'avatar',
                type: FormFieldType.avatarPicker,
                extra: {
                  'username': initialUser?.name ?? 'User',
                  'size': 60.0,
                  'saveDirectory': 'avatars',
                },
              ),
            ],
            submitButtonText: '保存',
            showResetButton: true,
            fieldSpacing: 16,
            onSubmit: (values) {
              final user = User(
                name: values['name'] as String,
                email: values['email'] as String,
                role: values['role'] as String,
                isActive: values['isActive'] as bool,
                icon: values['icon'] as IconData?,
                avatar: values['avatar'] as String?,
              );
              Navigator.pop(context, user);
            },
            onReset: () {
              // 重置为初始值
            },
            onValidationFailed: (errors) {
              ScaffoldMessenger.of(context).showSnackBar(
                SnackBar(
                  content: Text('请检查输入:${errors.values.join(", ")}'),
                ),
              );
            },
          ),
        ),
      ),
    );
  }
}

表单联动与回调机制

FormBuilderWrapper 通过 onChanged 回调和 visible 条件函数支持复杂的表单联动场景。

联动场景类型

  1. 字段值联动: 一个字段变化时自动更新其他字段
  2. 条件显示: 根据某些字段的值显示/隐藏其他字段
  3. 动态按钮: 输入框前后按钮触发额外操作
  4. 级联选择: 选择器级联更新选项

技术实现

// 使用 onChanged 回调
FormFieldConfig(
  name: 'country',
  type: FormFieldType.select,
  onChanged: (value) {
    // 联动更新城市选项
    _updateCities(value);
  },
),

// 使用 visible 条件函数
FormFieldConfig(
  name: 'city',
  type: FormFieldType.select,
  visible: (values) => values['country'] != null,
),

// 使用 patchValue 更新其他字段
void _updateProviderFields(ServiceProvider provider) {
  _formKey.currentState?.patchValue({
    'baseUrl': provider.baseUrl,
    'headers': _formatHeaders(provider.headers),
  });
}

Benefits

代码减少

  • 状态管理: 减少 50-70% 的状态变量
  • UI 代码: 减少 60-80% 的 UI 样板代码
  • 验证逻辑: 统一的验证机制

可维护性提升

  • 一致性: 所有表单使用统一的 API
  • 可测试性: 配置化的表单更容易测试
  • 可扩展性: 添加新字段类型只需添加对应的 field 组件

功能增强

  • 自动验证: 内置必填字段验证
  • 数据收集: 自动收集所有字段值
  • 重置功能: 免费获得表单重置功能

Best Practices

1. 字段命名

使用描述性的字段名称:

FormFieldConfig(
  name: 'userEmail',        // ✅ 清晰
  // name: 'email',         // ⚠️ 可能在上下文中模糊
)

2. 初始值处理

确保初始值类型正确:

FormFieldConfig(
  name: 'age',
  type: FormFieldType.number,
  initialValue: int.tryParse(user.age) ?? 0,  // ✅ 安全转换
  // initialValue: user.age,  // ❌ 可能是 String
)

3. 条件渲染

对于条件字段,使用单独的表单或动态配置:

final fields = <FormFieldConfig>[
  FormFieldConfig(name: 'type', type: FormFieldType.select, ...),
  // 根据类型添加额外字段
  if (selectedType == 'premium') ...[
    FormFieldConfig(name: 'premiumFeatures', ...),
  ],
];

4. 自定义验证

对于复杂验证,使用 onValidationFailed 回调:

onValidationFailed: (errors) {
  // 自定义错误处理
  if (errors.containsKey('email')) {
    _showEmailError();
  }
},

Testing Checklist

转换完成后验证:

  • flutter analyze 无错误
  • 所有字段都能正确显示
  • 值变化能正确触发 onChanged
  • 必填字段验证正常工作
  • 提交按钮能收集所有字段值(包括 WrappedFormField)
  • 如果使用外部提交按钮:验证 onStateReady 被调用且 submitForm() 正确触发
  • 重置按钮能恢复初始值
  • 国际化文本正确显示
  • Picker 字段对话框能正常打开

最佳实践总结

1. 提交按钮位置选择

场景推荐做法
表单在页面主体使用 FormBuilderWrapperbuttonBuildershowSubmitButton: true
表单在弹窗/底部抽屉同上
表单在复杂页面(按钮在 AppBar)使用 onStateReady + _wrapperState.submitForm()
按钮在多个位置使用 FormBuilderWrapperStatesubmitForm() 方法

2. 为什么不能直接用 formKey.value

  • FormBuilder.value 只包含通过 FormBuilderField 注册的字段
  • WrappedFormField 使用自己的状态管理,不注册到 FormBuilder
  • FormBuilderWrapperState.submitForm() 会正确保存并合并所有字段值

3. 必须记住的三件事

1. 声明状态变量:FormBuilderWrapperState? _wrapperState;
2. 获取状态:onStateReady: (state) => _wrapperState = state,
3. 触发提交:_wrapperState?.submitForm();

Troubleshooting

AppBar 按钮无法收集表单值

症状: 点击 AppBar 保存按钮后,onSubmit 接收到的 values 为空或只有部分字段

原因: 使用外部按钮直接调用 _formKey.currentState?.value,但 WrappedFormField 不会自动注册到 FormBuilder

// ❌ 错误:直接在外部按钮中使用 formKey.value
TextButton(
  onPressed: () {
    final values = _formKey.currentState?.value ?? {};
    _handleSubmit(values);  // values 可能是空的!
  },
  child: Text('保存'),
)

// ✅ 正确:使用 onStateReady + submitForm
class _MyFormScreenState extends State<MyFormScreen> {
  FormBuilderWrapperState? _wrapperState;

  FormBuilderWrapper(
    onStateReady: (state) => _wrapperState = state,
    ...
  );

  TextButton(
    onPressed: () => _wrapperState?.submitForm(),
    child: Text('保存'),
  )
}

formKey 参数错误

症状: _formKey.currentState 始终为 null

// ❌ 错误:使用 key 参数
FormBuilderWrapper(
  key: _formKey,  // 这是 Flutter widget key,不是 FormBuilder key
)

// ✅ 正确:使用 formKey 参数
FormBuilderWrapper(
  formKey: _formKey,
)

字段值未被收集

症状: 保存时 values 只包含部分字段或为空

原因: WrappedFormField 不会将值注册到 FormBuilder,必须使用 FormBuilderFieldflutter_form_builder 内置字段

// ❌ 错误:使用 WrappedFormField
return WrappedFormField(
  name: config.name,
  builder: (context, value, setValue) => MyField(...),
);

// ✅ 正确:使用 FormBuilderField
return FormBuilderField<String>(
  name: config.name,
  initialValue: config.initialValue?.toString() ?? '',
  builder: (fieldState) => MyField(
    value: fieldState.value,
    onChanged: (v) => fieldState.didChange(v),  // 关键:通知 FormBuilder
  ),
);

// ✅ 或使用内置字段:FormBuilderTextField
return FormBuilderTextField(
  name: config.name,
  initialValue: config.initialValue?.toString() ?? '',
);

buttonBuilder 参数位置错误

症状: buttonBuilder 参数未定义错误

// ❌ 错误:放在 config 中
FormBuilderWrapper(
  config: FormConfig(
    buttonBuilder: ...,  // FormConfig 没有这个参数
  ),
)

// ✅ 正确:作为 FormBuilderWrapper 的直接参数
FormBuilderWrapper(
  buttonBuilder: (context, onSubmit, onReset) => ElevatedButton(...),
  config: FormConfig(...),
)

类型转换错误

确保 initialValue 类型与字段类型匹配:

// ❌ 错误
FormFieldConfig(
  name: 'count',
  type: FormFieldType.number,
  initialValue: '123',  // String 类型
)

// ✅ 正确
FormFieldConfig(
  name: 'count',
  type: FormFieldType.number,
  initialValue: 123,  // int 类型
)

Dropdown value 不在 items 中

症状: DropdownButton value assertion error

// ❌ 错误:initialValue 可能不在 items 列表中
FormFieldConfig(
  name: 'provider',
  initialValue: _selectedProviderId,  // 可能为空字符串或不存在
  items: _providers.map(...).toList(),
)

// ✅ 正确:确保 initialValue 在 items 中
FormFieldConfig(
  name: 'provider',
  initialValue: _providers.any((p) => p.id == _selectedProviderId)
      ? _selectedProviderId
      : (_providers.isNotEmpty ? _providers.first.id : null),
  items: _providers.map(...).toList(),
)

Picker 不显示

检查是否有对应的 field 文件和正确的 FormFieldType:

// 确保在 form_builder_wrapper.dart 中有:
case FormFieldType.myPicker:
  return _buildMyPickerField(config, fieldKey!);

验证不生效

确保设置了 required: true

FormFieldConfig(
  name: 'email',
  type: FormFieldType.email,
  required: true,  // 必需
  validationMessage: '邮箱不能为空',
)

Notes

  • 转换前建议先创建备份或使用版本控制
  • 使用 --dry-run 参数预览修改
  • 复杂表单可以分步骤转换
  • 保留原有的业务逻辑代码,只转换 UI 部分
  • 使用中文注释与现有代码库保持一致
  • 运行 flutter analyze 验证转换后的代码