Skip to content

Forms & validation

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():

project_form.dart
Source · editable
import 'package:fleury/fleury_core.dart';
class ProjectForm extends StatefulWidget {
const ProjectForm({super.key});
@override
State<ProjectForm> createState() => _ProjectFormState();
}
class _ProjectFormState extends State<ProjectForm> {
final form = FormController();
final name = TextEditingController();
final slug = TextEditingController();
String status = 'Fill in the project details';
@override
void dispose() {
form.dispose();
name.dispose();
slug.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) => Padding(
padding: const EdgeInsets.all(1),
child: Form(
controller: form,
onSubmit: () => setState(() {
status =
'Created ${name.text.trim()} '
'(${slug.text.trim()})';
}),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('Name'),
FormField(
validator: () => name.text.trim().isEmpty
? 'Enter a project name.'
: null,
child: TextInput(
controller: name,
semanticLabel: 'Name',
placeholder: 'e.g. My project',
autofocus: true,
),
),
const SizedBox(height: 1),
const Text('Slug'),
FormField(
validator: () =>
RegExp(
r'^[a-z0-9-]+$',
).hasMatch(slug.text.trim())
? null
: 'Use lowercase letters, numbers, and hyphens.',
child: TextInput(
controller: slug,
semanticLabel: 'Slug',
placeholder: 'e.g. my-project',
onSubmit: (_) => form.submit(),
),
),
const SizedBox(height: 1),
Button(text: 'Create', onPressed: form.submit),
const SizedBox(height: 1),
Text('status: $status'),
],
),
),
);
}
Create a project
Live · interactive
click & type to interact ⓘ how this demo runs
Test references
import 'package:fleury/fleury.dart';
import 'package:fleury_test/fleury_test.dart';
import 'package:test/test.dart';
import '../../lib/forms/project_form.dart';
void main() {
testWidgets(
'submit, correct the errors, then submit with Enter',
(tester) async {
tester.pumpWidget(const ProjectForm());
await tester.button('Create').press();
await tester.settle();
expect(tester.field('Name'), isFocused);
expect(
tester.renderToString(),
contains('Enter a project name.'),
);
expect(
tester.renderToString(),
isNot(contains('Created')),
);
await tester.field('Name').fill('Fleury');
await tester.field('Slug').fill('fleury-app');
await tester.settle();
expect(tester.field('Slug'), isFocused);
expect(
tester.renderToString(),
isNot(contains('Enter a project name.')),
);
tester.press(KeySequence.enter);
await tester.settle();
expect(
tester.renderToString(),
contains('Created Fleury (fleury-app)'),
);
},
viewportSize: const CellSize(40, 18),
);
}

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:

save_project.dart
Source · editable
import 'package:fleury/fleury_core.dart';
// A local service simulation; this demo sends no requests.
enum SaveScenario { success, nameTaken, offline }
enum SaveReply { saved, nameTaken }
class ServiceUnavailable implements Exception {}
Future<SaveReply> simulateSave(
String name,
SaveScenario scenario,
) async {
await Future<void>.delayed(const Duration(seconds: 1));
if (scenario == SaveScenario.offline) {
throw ServiceUnavailable();
}
return scenario == SaveScenario.nameTaken
? SaveReply.nameTaken
: SaveReply.saved;
}
class SaveProject extends StatefulWidget {
const SaveProject({super.key, this.save = simulateSave});
final Future<SaveReply> Function(String, SaveScenario)
save;
@override
State<SaveProject> createState() => _SaveProjectState();
}
class _SaveProjectState extends State<SaveProject> {
final form = FormController();
final name = TextEditingController(text: 'Atlas');
SaveScenario scenario = SaveScenario.success;
String? nameError;
String status = 'Ready to save';
Future<void> save() async {
// Snapshot the submitted value before the asynchronous work.
final submittedName = name.text.trim();
setState(() => status = 'Saving $submittedName…');
try {
final reply = await widget.save(
submittedName,
scenario,
);
if (!mounted) return;
if (reply == SaveReply.nameTaken) {
setState(() {
nameError = 'That name is taken. Try another.';
status = 'Choose another name';
});
// submit() applies this error, focuses Name, and returns false.
return;
}
// Success-only actions (including closing a dialog) belong here.
setState(() => status = 'Saved $submittedName');
} on ServiceUnavailable {
if (mounted) {
setState(
() => status =
'Offline. Your draft is kept. Retry.',
);
}
rethrow; // An awaiting caller must not mistake this for success.
}
}
Future<void> submit() async {
try {
await form.submit();
} on ServiceUnavailable {
// save() already displayed this expected failure. Keep the UI open.
}
}
@override
void dispose() {
form.dispose();
name.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) => Padding(
padding: const EdgeInsets.all(1),
child: NotifierBuilder(
notifier: form,
builder: (context, _) => Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('Simulated server response'),
Select<SaveScenario>(
semanticLabel: 'Server response',
value: scenario,
onChanged: form.isBusy
? null
: (value) =>
setState(() => scenario = value),
options: const [
SelectOption(
value: SaveScenario.success,
label: 'Success',
),
SelectOption(
value: SaveScenario.nameTaken,
label: 'Name taken',
),
SelectOption(
value: SaveScenario.offline,
label: 'Offline',
),
],
),
const SizedBox(height: 1),
Form(
controller: form,
onSubmit: save,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('Name'),
FormField(
error: nameError,
validator: () => name.text.trim().isEmpty
? 'Enter a project name.'
: null,
child: TextInput(
controller: name,
semanticLabel: 'Name',
readOnly: form.isSubmitting,
onChanged: (_) =>
setState(() => nameError = null),
onSubmit: (_) => submit(),
),
),
const SizedBox(height: 1),
Button(
text: form.isBusy ? 'Saving…' : 'Save',
onPressed: form.isBusy ? null : submit,
),
],
),
),
const SizedBox(height: 1),
Text(status),
],
),
),
);
}
Save a project
Live · interactive
click & type to interact ⓘ how this demo runs
Test references
import 'dart:async';
import 'package:fleury/fleury.dart';
import 'package:fleury_test/fleury_test.dart';
import 'package:test/test.dart';
import '../../lib/forms/save_project.dart';
void main() {
testWidgets(
'server field error preserves the draft and supports retry',
(tester) async {
var pending = Completer<SaveReply>();
final savedNames = <String>[];
tester.pumpWidget(
SaveProject(
save: (name, _) {
savedNames.add(name);
return pending.future;
},
),
);
await tester.button('Save').press();
await tester.settle();
expect(tester.button('Saving…'), isDisabled);
expect(
tester.field('Name').snapshot.state.readOnly,
isTrue,
);
expect(savedNames, ['Atlas']);
pending.complete(SaveReply.nameTaken);
await tester.settle();
expect(
tester.renderToString(),
contains('That name is taken.'),
);
expect(tester.field('Name'), isFocused);
expect(tester.field('Name'), hasValue('Atlas'));
expect(tester.button('Save'), isEnabled);
await tester.field('Name').fill('Fleury');
await tester.settle();
expect(
tester.renderToString(),
isNot(contains('That name is taken.')),
);
pending = Completer<SaveReply>();
await tester.button('Save').press();
await tester.settle();
pending.complete(SaveReply.saved);
await tester.settle();
expect(savedNames, ['Atlas', 'Fleury']);
expect(
tester.renderToString(),
contains('Saved Fleury'),
);
},
viewportSize: const CellSize(40, 16),
);
testWidgets(
'offline failure keeps the draft and Enter retries it',
(tester) async {
var offline = true;
tester.pumpWidget(
SaveProject(
save: (_, _) async {
if (offline) throw ServiceUnavailable();
return SaveReply.saved;
},
),
);
await tester.button('Save').press();
await tester.settle();
expect(
tester.renderToString(),
contains('Offline. Your draft is kept. Retry.'),
);
expect(tester.field('Name'), hasValue('Atlas'));
expect(tester.button('Save'), isEnabled);
offline = false;
await tester.field('Name').focus();
tester.press(KeySequence.enter);
await tester.settle();
expect(
tester.renderToString(),
contains('Saved Atlas'),
);
},
viewportSize: const CellSize(40, 16),
);
testWidgets(
'a pending save can finish after the form closes',
(tester) async {
final pending = Completer<SaveReply>();
tester.pumpWidget(
SaveProject(save: (_, _) => pending.future),
);
await tester.button('Save').press();
await tester.settle();
tester.pumpWidget(const Text('Closed'));
pending.complete(SaveReply.saved);
await tester.settle();
expect(tester.renderToString(), contains('Closed'));
},
);
}

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:

related_fields.dart
Source · editable
import 'package:fleury/fleury_core.dart';
class RelatedFields extends StatefulWidget {
const RelatedFields({super.key});
@override
State<RelatedFields> createState() =>
_RelatedFieldsState();
}
class _RelatedFieldsState extends State<RelatedFields> {
final form = FormController();
final password = TextEditingController();
final confirmation = TextEditingController();
String status = 'Use made-up values in this demo';
Future<void> check() async {
final valid = await form.validate(autofocus: false);
if (!mounted) return;
setState(
() => status = valid
? 'Values match'
: 'Check the errors',
);
}
@override
void dispose() {
form.dispose();
password.dispose();
confirmation.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) => Padding(
padding: const EdgeInsets.all(1),
child: Form(
controller: form,
onSubmit: () => setState(() => status = 'Confirmed'),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('Password'),
FormField(
validator: () => password.text.isEmpty
? 'Enter a password.'
: null,
child: PasswordInput(
controller: password,
semanticLabel: 'Password',
autofocus: true,
),
),
const SizedBox(height: 1),
const Text('Confirm password'),
FormField(
validator: () => confirmation.text.isEmpty
? 'Repeat the password.'
: confirmation.text != password.text
? 'Passwords must match.'
: null,
child: PasswordInput(
controller: confirmation,
semanticLabel: 'Confirm password',
onSubmit: (_) => form.submit(),
),
),
const SizedBox(height: 1),
Wrap(
spacing: 1,
children: [
Button(text: 'Check', onPressed: check),
Button(
text: 'Confirm',
onPressed: form.submit,
),
],
),
const SizedBox(height: 1),
Text(status),
],
),
),
);
}
Check a password confirmation
Live · interactive
click & type to interact ⓘ how this demo runs
Test references
import 'package:fleury/fleury.dart';
import 'package:fleury_test/fleury_test.dart';
import 'package:test/test.dart';
import '../../lib/forms/related_fields.dart';
void main() {
testWidgets(
'Check preserves focus; Confirm focuses the error',
(tester) async {
tester.pumpWidget(const RelatedFields());
await tester.field('Password').fill('made-up-one');
await tester
.field('Confirm password')
.fill('made-up-two');
await tester.button('Check').focus();
await tester.button('Check').press();
await tester.settle();
expect(tester.button('Check'), isFocused);
expect(
tester.renderToString(),
contains('Passwords must match.'),
);
await tester.button('Confirm').press();
await tester.settle();
expect(tester.field('Confirm password'), isFocused);
await tester
.field('Confirm password')
.fill('made-up-one');
await tester.settle();
expect(
tester.renderToString(),
isNot(contains('Passwords must match.')),
);
// Changing the other field also refreshes the revealed comparison.
await tester.field('Password').fill('made-up-three');
await tester.settle();
expect(
tester.renderToString(),
contains('Passwords must match.'),
);
expect(tester.field('Password'), isFocused);
await tester
.field('Confirm password')
.fill('made-up-three');
await tester.button('Confirm').press();
await tester.settle();
expect(
tester.renderToString(),
contains('Confirmed'),
);
},
viewportSize: const CellSize(40, 18),
);
}

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:

custom_field.dart
Source · editable
import 'package:fleury/fleury_core.dart';
class CustomField extends StatefulWidget {
const CustomField({super.key});
@override
State<CustomField> createState() => _CustomFieldState();
}
class _CustomFieldState extends State<CustomField> {
final form = FormController();
num start = 5;
num end = 3;
String status = 'Set an end greater than the start';
@override
void dispose() {
form.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) => Padding(
padding: const EdgeInsets.all(1),
child: Form(
controller: form,
onSubmit: () => setState(
() => status = 'Saved range $start–$end',
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('Range'),
FormField.builder(
validator: () => end > start
? null
: 'End must be greater than start.',
builder: (context, field) => Semantics(
role: SemanticRole.region,
label: 'Range',
validationError: field.error,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment:
CrossAxisAlignment.start,
children: [
Stepper(
label: 'Start',
value: start,
min: 0,
max: 9,
focusNode: field.focusNode,
onChanged: (value) {
setState(() => start = value);
field.valueChanged();
},
),
Stepper(
label: 'End',
value: end,
min: 0,
max: 9,
onChanged: (value) {
setState(() => end = value);
field.valueChanged();
},
),
],
),
),
),
const SizedBox(height: 1),
const _SaveRangeButton(),
const SizedBox(height: 1),
Text(status),
],
),
),
);
}
class _SaveRangeButton extends StatelessWidget {
const _SaveRangeButton();
@override
Widget build(BuildContext context) {
// Form.of also rebuilds this widget when the controller changes.
final form = Form.of(context);
return Button(
text: 'Save range',
onPressed: form.isBusy ? null : form.submit,
);
}
}
Edit a range
Live · interactive
click & type to interact ⓘ how this demo runs
Test references
import 'package:fleury/fleury.dart';
import 'package:fleury_test/fleury_test.dart';
import 'package:test/test.dart';
import '../../lib/forms/custom_field.dart';
void main() {
testWidgets(
'a composite field focuses its chosen control and refreshes',
(tester) async {
tester.pumpWidget(const CustomField());
await tester.button('Save range').press();
await tester.settle();
final start = tester.target(
role: SemanticRole.spinButton,
label: 'Start',
);
final end = tester.target(
role: SemanticRole.spinButton,
label: 'End',
);
expect(start, isFocused);
expect(
tester.renderToString(),
contains('End must be greater than start.'),
);
await end.setValue(8);
await tester.settle();
expect(
tester.renderToString(),
isNot(contains('End must be greater than start.')),
);
await tester.button('Save range').press();
await tester.settle();
expect(
tester.renderToString(),
contains('Saved range 5–8'),
);
},
viewportSize: const CellSize(40, 12),
);
}

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.

The Form, FormField, and FormController references list their options, and the Deployment form showcase puts these patterns into a three-screen flow. Continue with Navigation for protecting unsaved changes, or Testing for the tester API used by the tests above.

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:

Terminal window
cd website/examples && dart test test/forms_guide_test.dart