In Fleury, a form groups ordinary input controls into one validation and
submission flow. Your application owns the values and validation rules; the
form coordinates error feedback, focus, and submission. The same setup works
for built-in inputs and custom controls.
Start with a project name and a slug, a short identifier such as my-project.
Both are required, and the slug accepts only lowercase letters, numbers, and
hyphens. The project should be created only when both values pass those checks.
Each input sits inside a FormField with its validation rule. A Form groups
the fields and calls onSubmit once they are valid. The Create button and
Enter in the Slug input both reach it through form.submit():
Choose Create with both fields empty. Each field explains what it needs,
and focus moves to Name so you can begin correcting it. Enter a name and
slug, then press Enter in Slug or choose Create again to finish.
A validator reads the current value and returns an error message, or null
when it is valid. Fields start without errors; submitting reveals them. Once
feedback is visible, editing a value refreshes it automatically without moving
focus away from what you are typing. On an invalid submission, the form focuses
the first error and, inside a ScrollView, scrolls it into view.
The values remain in your application state. Here, two TextEditingControllers
hold the text, while FormController coordinates validation and submission.
Create the controllers once in State and dispose them with it. FormField
connects to its child control to show the error and apply invalid styling;
the visible label and the input’s matching semanticLabel identify the field.
Passing local validation does not guarantee that a save will succeed. A server
might reject a name that is already in use, or the connection might fail. The
next project form adds an asynchronous save, progress feedback, and a way to
recover while keeping the entered name.
The dropdown simulates three server responses locally. Nothing is sent over
the network:
Choose Name taken, then Save. The error belongs to Name because changing
that value can resolve it. Edit the name, switch to Success, and save again.
With Offline, the form instead shows a status message and leaves the draft
ready for another attempt.
Server errors use FormField.error. Store the message in application state
and clear it when the user edits the affected value, as this example does in
onChanged. An error affecting the whole request, such as being offline,
belongs in the form’s status message.
onSubmit can return a future. isSubmitting is true while that future runs;
the example uses it to keep the name read-only while saving, so the result
still refers to the value on screen. isBusy covers the whole attempt,
validation included, and the example uses it to disable Save.
Lock fields with isSubmitting, not isBusy. Validation skips disabled
fields, and isBusy is already true while the form validates, so a field
disabled on isBusy is never validated and the form submits its value
unchecked.
submit() returns false if validation fails or onSubmit leaves a field
error. Setting FormField.error in the callback is enough; there is no second
validation call to make. Exceptions from the save propagate to an awaiting
caller; when nothing awaits the submit, as with a button, Enter, or an agent’s
submit action, runApp reports the error and the app keeps running. For
Offline, the example shows the failure and catches that expected
exception in the handler shared by Save and Enter, leaving the form open.
The save callback takes a snapshot of the submitted name and checks mounted
before updating the UI after the wait. Any action that depends on success,
such as closing a dialog, belongs after the service has accepted the save.
For a reset action, reset your application’s values and server errors, then
call clearErrors() to hide validator feedback. The form does not discard
entered values itself.
Some rules depend on more than one input. A password confirmation is valid
only when it matches the password, so its validator reads both values from
application state. Once errors are visible, changing either value refreshes
the comparison.
You can also check a form without submitting it. Here, Check displays
feedback while leaving focus on the button, so the user can decide what to
edit next. Confirm submits the form and focuses the first invalid field:
Enter two different made-up passwords and choose Check, then Confirm.
Both show the mismatch; only Confirm moves focus to the correction. Make the
values match and the error clears.
Check calls validate(autofocus: false). This still displays errors, but leaves
focus and scrolling alone. The default, validate(), focuses the first invalid
field. Unlike a control’s initial autofocus, this option applies each time you
ask the form to validate.
Keep validators synchronous: read the relevant values and return an error.
validate() itself returns a future so it can wait for pending widget updates
before checking. Checks that need a server belong in the asynchronous save
flow above.
Sometimes several controls represent one value. A range has a start and an
end, but its validation rule describes the range as a whole: End must be greater
than Start. FormField.builder lets that pair participate in the same validation
and submission flow as an ordinary input.
The example uses two steppers and one shared error. It chooses Start as the
place to focus when the range needs correcting:
Choose Save range to reveal the error, then increase End above Start using
the stepper’s arrows or + button. The message clears as the range becomes
valid, and Save range can now complete.
The builder receives a field that connects the controls to the form. Attach
field.focusNode to the control that should receive focus, call
field.valueChanged() after updating either value, and use field.error for
custom styling or semantics. The field displays the error message below the
pair by default. Separate, independent values should each have their own
FormField.
The Save range button lives in its own widget. It uses Form.of(context) to
reach the surrounding form’s controller, and rebuilds when that controller’s
state changes. You can use this for actions inside a form without passing the
controller through every widget. When all actions live inside the form, you
can omit its controller argument and let the form create one.
Each demo’s source and its Test references are the files behind the live
example. They live in
website/examples/lib/forms
and
website/examples/test/forms.
To run all four tests, use this command from the root of a clone of the
Fleury repository: