keyed_form

Quickstart

Define a login schema, generate typed fields, and connect the model to a Flutter form.

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#

dart
@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#

dart
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:

dart
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.