This walkthrough follows the working login example: schema source, generated part, controller, descendant-context submission, and controller disposal. The full app also extracts its text field widget into a separate source file.
Define and generate the schema#
@keyedSchema
library;
import 'package:keyed_form_schema/keyed_form_schema.dart';
part 'login_schema.kfg.dart';
final _loginSchema = ks.object({
'email': ks.string(error: .text('Enter your email'))
.email(error: .text('That does not look like an email')),
'password': ks.string(error: .text('Enter your password'))
.min(8, error: .text('At least 8 characters')),
});
Add the schema/runtime dependencies and generator as described in Installation, then run
dart run build_runner build -d. The @keyedSchema library annotation and matching part declaration are required for the generated file.
Connect the controller and widgets#
final form = KeyedFormController<LoginSchema>(
initialValue: LoginSchema.create(),
mode: KeyedFormMode.onTouched,
resolver: LoginSchema.validateData,
);
@override
void dispose() {
form.dispose();
super.dispose();
}
Place the fields beneath KeyedForm<LoginSchema>(controller: form, ...) and bind them to
LoginFields.email and LoginFields.password. handleSubmit needs a
BuildContext below that KeyedForm; use a Builder when the button is declared alongside the scope:
Builder(
builder: (context) => FilledButton(
onPressed: () => form.handleSubmit(
context,
(value) => onSignedIn(value),
),
child: const Text('Sign in'),
),
)
The submission callback runs only for a valid form. The owner disposes the controller when its state is removed.
KeyedFormMode.onTouched matches the embedded example: an error becomes visible after interaction rather than immediately on initial render.
@keyedSchema
library;
import 'package:keyed_form_schema/keyed_form_schema.dart';
part 'login_schema.kfg.dart';
final _loginSchema = ks.object({
'email': ks
.string(error: .text('Enter your email'))
.email(error: .text('That does not look like an email')),
'password': ks
.string(error: .text('Enter your password'))
.min(8, error: .text('At least 8 characters')),
});
import 'package:flutter/material.dart';
import 'package:keyed_form_flutter/keyed_form_flutter.dart';
import 'login_schema.dart';
part 'login_text_field.dart';
class LoginForm extends StatefulWidget {
const LoginForm({super.key, this.onSignedIn});
final void Function(LoginSchema value)? onSignedIn;
@override
State<LoginForm> createState() => _LoginFormState();
}
class _LoginFormState extends State<LoginForm> {
final form = KeyedFormController<LoginSchema>(
initialValue: LoginSchema.create(),
mode: KeyedFormMode.onTouched,
resolver: LoginSchema.validateData,
);
@override
void dispose() {
form.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return KeyedForm<LoginSchema>(
controller: form,
child: Column(
children: [
_LoginTextField(field: LoginFields.email, label: 'Email'),
const SizedBox(height: 16),
_LoginTextField(
field: LoginFields.password,
label: 'Password',
obscureText: true,
),
const SizedBox(height: 16),
// handleSubmit needs a context below KeyedForm.
Builder(
builder: (context) => FilledButton(
onPressed: () => form.handleSubmit(
context,
(value) => widget.onSignedIn?.call(value),
),
child: const Text('Sign in'),
),
),
],
),
);
}
}
part of 'login_form.dart';
class _LoginTextField extends StatelessWidget {
const _LoginTextField({
required this.field,
required this.label,
this.obscureText = false,
});
final FieldRef<LoginSchema, String> field;
final String label;
final bool obscureText;
@override
Widget build(BuildContext context) {
return KeyedFormField.text<LoginSchema>(
field: field,
builder: (context, f, controller) => TextField(
controller: controller,
obscureText: obscureText,
onTapOutside: (_) => f.onBlur(),
decoration: InputDecoration(
labelText: label,
errorText: f.errorText,
border: const OutlineInputBorder(),
),
),
);
}
}
Continue with Form State for controller behavior and Flutter for binding details.