flutterx_forms

Typed Flutter form fields with validators and one form controller: text, integer and double with arrows, checkbox and switch, dropdowns, date and time, file and files with drop on the web, lists of fields, tabs, chips and dynamic inner forms.

Installation

flutter pub add flutterx_forms
import 'package:flutterx_forms/flutterx_forms.dart';

Setup

Mix FormController into the State of the form. Every field has a handle, XFormField<T>, created with field() before the form is built: it gives the value, the focus node and the controller. The mixin builds the Form, disposes the handles with the state and calls onFormSubmit when the form is valid.

class _LoginState extends State<Login> with FormController<Login> {
  late final XFormField<String> _email = field();
  late final XFormField<String> _password = field();

  @override
  Widget build(BuildContext context) => buildForm(
    autovalidateMode: AutovalidateMode.onUserInteraction,
    children: [
      XTextFormField(
        field: _email,
        decoration: const InputDecoration(labelText: 'Email'),
        textInputAction: TextInputAction.next,
        validator: validateFirst([nonBlank(() => 'Required'), matchesEmail(() => 'Not an email')]),
      ),
      XTextFormField(field: _password, obscureText: true, validator: minLen(8, () => 'At least 8 characters')),
      FilledButton(onPressed: submitForm, child: const Text('Sign in')),
    ],
  );

  @override
  Future<void> onFormSubmit() => signIn(_email.value, _password.value);
}

submitForm() validates, unfocuses and runs onFormSubmit; validateForm(), resetForm() and saveForm() act on every field. formFields lists the attached handles; with autoscroll overridden to true a failed submit focuses the first invalid field and scrolls to it. A handle read before its field is built fails: use valueOrNull or attached.

controller() returns the text controller of the handle, created on the first call: TextEditingController for String, IntEditingController for int, DoubleEditingController for double (also nullable), TextListEditingController for List<String>. Any other T throws UnsupportedError: pass a controllerFactory to field().

Fields

Every field takes its handle as field: and an optional initialValue, that applies only when the handle has no controller yet. Validators take the non-null value.

late final XFormField<int?> _age = field();
late final XFormField<bool> _terms = field();
late final XFormField<MaterialColor> _color = field();
late final XFormField<DateTime?> _birthday = field();
late final XFormField<TimeOfDay?> _alarm = field();
late final XFormField<XFile?> _photo = field();

XIntFormField(field: _age, min: 18, max: 120, arrows: const NumberFieldArrows(step: 5)),
XBoolFormField.switchbox(field: _terms, titleText: 'I accept', validator: (value) => value ? null : 'Required'),
XDropdownButtonFormField<MaterialColor>(field: _color, items: Colors.primaries),
XDateTimeFormField(field: _birthday, lastDate: DateTime.now()),
XTimeFormField(field: _alarm),
XFileFormField(field: _photo, mimeTypes: const {'image/*'}),
  • Text: XTextFormField (XFormField<String>).
  • Numbers: XIntFormField and XDoubleFormField (XFormField<int?>, XFormField<double?>) take min, max and arrows; NumberFieldArrows sets step, onlyArrows and roundToStep. IntField and DoubleField are the same inputs outside a form.
  • Bool: XBoolFormField.checkbox and .switchbox.
  • Dropdown: XDropdownButtonFormField and XDropdownMenuFormField (with menuEntryByLabel).
  • Date and time: XDateTimeFormField (firstDate, lastDate, includeTime, dateFormat) and XTimeFormField.
  • Files: XFileFormField (XFormField<XFile?>) and XFilesFormField (XFormField<List<XFile>>) take mimeTypes and onMimeTypeNotMatch, and accept files dropped on them on the web. XDropView is the standalone drop target.

Lists

XListFormField<E> shows a row per item with add and remove buttons; the itemBuilder gets the handle of each item. XSetFormField<E> lets each row choose among items not chosen by the other rows. XListTabsFormField<E> puts each item in a tab. XTextListFormField takes words typed in one text field and shows them as chips (List<String>).

XListFormField<String>(
  field: _tags,                                    // XFormField<List<String>>
  decoration: const InputDecoration(labelText: 'Tags'),
  maxLength: 3,
  addButtonText: 'Add tag',
  itemInitialValue: (values, index) => '',
  validator: listUnique<String>(() => 'Duplicated tag'),
  itemBuilder: (state, field, values, index) => XTextFormField(field: field, initialValue: values[index]),
),

Dynamic inner forms

ProxyFormField<T> computes its value from inner fields that live in an XFormFieldStore: create, remove, reorder and read them by id.

ProxyFormField<List<String>>(
  field: _phones,                                  // XFormField<List<String>>
  initialValue: const [],
  initializeFields: (value, fields) => fields.addFields<String>({for (var i = 0; i < value.length; i++) '$i'}),
  computeFieldData: (values) => values.values.cast<String>().toList(),
  builder: (state, fields) => Column(
    children: [
      for (final id in fields.fields.keys) XTextFormField(field: fields.field<String>(id)),
      TextButton(onPressed: fields.addUniqueField<String>, child: const Text('Add')),
    ],
  ),
),

The inner fields are validated, saved and reset with the outer form; computeErrorText builds the error from the invalid ones. validateUnique and validateUniqueBy reject a value repeated among the fields of a store.

Validators

nonEmpty, nonBlank, nonNull, minLen, maxLen, matches, matchesEmail, contains, gt, gte, lt, lte, oneOf, notIn, fieldEquals, fieldNotEquals, listNonEmpty, listUnique, listUniqueBy. Each takes a message getter, so messages can be localized. Comparison and pattern validators accept a null value: pair them with nonNull.

validateFirst returns the first error of several validators; validateAll joins all errors (joiner prefixes each). fieldEquals(_password, () => 'Different') compares with another handle, for a confirmation field.

Decorations

SingleSideInputBorder(side:, margin:) draws one line on a side of the field, as a border of the InputDecoration. InputDecoratorLoader shows a progress indicator in the decorator while something loads. CustomContentInputDecorator puts any widget inside a themed decorator, as the bool and the arrows do.

Behavior

  • reset restores the value of the first construction, also after the form was rebuilt with another initialValue.
  • The handle disposes the focus node and the controller it created, also those of a controllerFactory; the controllers, scroll controllers and nodes you pass to a field stay yours to dispose.
  • Fields removed from an XFormFieldStore (also on reset and with the state) are disposed by the store.
  • Dates are formatted with the locale of the context: for a locale other than en_US call initializeDateFormatting first. The pickers start from clock.now(), which tests control with withClock.
  • XDropView, and so the drop on the file fields, receives files only on the web; elsewhere the dialog is the way in.
  • matches and contains clash with the matchers of flutter_test: in tests import it with hide contains, matches.

Testing

Replace the file dialog with a FileSelectorPlatform fake (file_selector_platform_interface, plugin_platform_interface): set FileSelectorPlatform.instance to a class that returns the chosen file from openFile and openFiles. Fields are driven with tester.enterText, submitForm() and resetForm().

Claude Code plugin

The repository ships a plugin with skills that teach Claude Code to integrate the library:

/plugin marketplace add https://gitlab.com/dade.sliep/flutterx_forms
/plugin install flutterx-forms@flutterx-forms

Libraries

flutterx_forms
Typed Flutter form fields with validators, a form controller and the text, number, date, file, list and drop widgets built on them.