ashlarui

Form

Fields of a form drawn with their labels and errors, checked when it is sent, focus on the first wrong one.

Form, as it looks in an app Form, as it looks in an app, dark
import 'package:flutter/widgets.dart';

import 'ui/button.dart';
import 'ui/checkbox.dart';
import 'ui/form.dart';
import 'ui/select.dart';

class FormDemo extends StatefulWidget {
  const FormDemo({super.key});

  @override
  State<FormDemo> createState() => _FormDemoState();
}

class _FormDemoState extends State<FormDemo> {
  final form = GlobalKey<FormState>();
  String? sent;

  void send() {
    if (form.currentState!.submit()) setState(() => sent = 'Account created.');
  }

  @override
  Widget build(BuildContext context) => SizedBox(
    width: 340,
    child: Form(
      key: form,
      child: Column(
        mainAxisSize: MainAxisSize.min,
        crossAxisAlignment: CrossAxisAlignment.stretch,
        children: [
          FormText(
            label: const Text('Name'),
            placeholder: 'Wanjiru Kamau',
            validator: Validate.required('Enter your name.'),
          ),
          const SizedBox(height: 20),
          FormText(
            label: const Text('Email'),
            placeholder: 'you@solutech.co.ke',
            keyboardType: TextInputType.emailAddress,
            validator: Validate.all([
              Validate.required('Enter your email.'),
              Validate.email(),
            ]),
          ),
          const SizedBox(height: 20),
          FormItem<String>(
            label: const Text('Team'),
            description: const Text('Who sees your work first.'),
            validator: Validate.required('Pick a team.'),
            builder: (field) => Select<String>(
              value: field.value,
              placeholder: 'Choose a team',
              error: field.hasError,
              options: const [
                SelectOption('design', 'Design'),
                SelectOption('engineering', 'Engineering'),
                SelectOption('sales', 'Sales'),
              ],
              onChanged: field.didChange,
            ),
          ),
          const SizedBox(height: 20),
          FormItem<bool>(
            initialValue: false,
            axis: Axis.horizontal,
            label: const Text('Accept the terms'),
            validator: Validate.required('Accept the terms to go on.'),
            builder: (field) => Checkbox(
              value: field.value!,
              error: field.hasError,
              onChanged: field.didChange,
            ),
          ),
          const SizedBox(height: 24),
          Button(onPressed: send, child: const Text('Create account')),
          if (sent != null) ...[const SizedBox(height: 12), Text(sent!)],
        ],
      ),
    ),
  );
}

Add it

In your Flutter project:

$ ashlar add form

It writes theme.dart, palette.dart, label.dart, field.dart, selection.dart, input.dart and form.dart into lib/ui, keeping any you already have. It needs field and input, which come with it.

Examples

Other ways to use it, each running live, with its code.

Sign in

Email and password, checked when sent.

Sign in, as it looks in an app Sign in, as it looks in an app, dark
// Sign in: Email and password, checked when sent.
import 'package:flutter/widgets.dart';

import 'ui/button.dart';
import 'ui/form.dart';

class FormLoginExample extends StatefulWidget {
  const FormLoginExample({super.key});

  @override
  State<FormLoginExample> createState() => _FormLoginExampleState();
}

class _FormLoginExampleState extends State<FormLoginExample> {
  final form = GlobalKey<FormState>();

  @override
  Widget build(BuildContext context) => SizedBox(
    width: 340,
    child: Form(
      key: form,
      child: Column(
        mainAxisSize: MainAxisSize.min,
        crossAxisAlignment: CrossAxisAlignment.stretch,
        children: [
          FormText(
            label: const Text('Email'),
            placeholder: 'you@solutech.co.ke',
            validator: Validate.all([Validate.required('Enter your email.'), Validate.email()]),
          ),
          const SizedBox(height: 16),
          FormText(label: const Text('Password'), obscureText: true, validator: Validate.minLength(8)),
          const SizedBox(height: 20),
          Button(onPressed: () => form.currentState!.submit(), child: const Text('Sign in')),
        ],
      ),
    ),
  );
}

Choices

A switch and a radio group, each checked.

Choices, as it looks in an app Choices, as it looks in an app, dark
// Choices: A switch and a radio group, each checked.
import 'package:flutter/widgets.dart';

import 'ui/button.dart';
import 'ui/form.dart';
import 'ui/radio.dart';
import 'ui/switch.dart';

class FormChoicesExample extends StatefulWidget {
  const FormChoicesExample({super.key});

  @override
  State<FormChoicesExample> createState() => _FormChoicesExampleState();
}

class _FormChoicesExampleState extends State<FormChoicesExample> {
  final form = GlobalKey<FormState>();

  @override
  Widget build(BuildContext context) => SizedBox(
    width: 340,
    child: Form(
      key: form,
      child: Column(
        mainAxisSize: MainAxisSize.min,
        crossAxisAlignment: CrossAxisAlignment.stretch,
        children: [
          FormItem<String>(
            label: const Text('Plan'),
            validator: Validate.required('Choose a plan.'),
            builder: (field) => RadioGroup<String>(
              groupValue: field.value,
              onChanged: field.didChange,
              child: Column(
                crossAxisAlignment: CrossAxisAlignment.start,
                children: [
                  Radio(value: 'monthly', label: const Text('Monthly'), error: field.hasError),
                  const SizedBox(height: 8),
                  Radio(value: 'yearly', label: const Text('Yearly'), error: field.hasError),
                ],
              ),
            ),
          ),
          const SizedBox(height: 16),
          FormItem<bool>(
            initialValue: false,
            axis: Axis.horizontal,
            label: const Text('Email me receipts'),
            builder: (field) => Switch(value: field.value!, onChanged: field.didChange),
          ),
          const SizedBox(height: 20),
          Button(onPressed: () => form.currentState!.submit(), child: const Text('Save')),
        ],
      ),
    ),
  );
}

API

Read from the source above: each parameter with its type, its default, and what it is for.

Validateclass

Checks for a field's validator: each gives the message to show, or null when the value is right. The messages are English; pass your own in other languages.

FormItemclass

A field of a Form, drawn as a Field: its label, a description and, once it has been checked, its error. builder makes the control from the field's state: give it field.value, report changes with field.didChange, and mark it with field.hasError.

Errors appear when the form is sent (FormSend.submit) and, after that, clear as each one is put right.

Parameter Type Default What it is for
builderrequired Widget Function(FormItemState<T> field) Builds the control from the field's state: its value, a way to change it, and whether it is wrong.
label Widget? Above the control, or beside it when across.
description Widget? Under the control: what the field is for.
axis Axis Axis.vertical Vertical puts the label above (inputs, selects); horizontal puts the control first (checkboxes, switches).
initialValue The value it starts with, and goes back to when the form is reset.
validator Checks the value: gives the message to show, or null when it is right.
onSaved Told the value when the form is saved, after every field is right.
enabled False fades it and leaves it out of the form's checks.
forceErrorText An error to show whatever the value: one from a server, say.
restorationId Keeps its value when the app is restored after the system closed it.
autovalidateMode AutovalidateMode.onUserInteractionIfError When it checks itself: by default once it has an error, as each change is made.

FormItemStateclass

The state of a FormItem: its value, its error, and focus to go to it.

FormTextclass

A text field of a Form: an Input drawn as a Field, kept in step with the form when it is reset.

Parameter Type Default What it is for
controller TextEditingController? Its text, to read and set from outside; one is made when not given.
initialValue String? The text it starts with, and goes back to when the form is reset.
labelWidget?Above the field.
description Widget? Under the field: what it is for.
placeholder String? Faint text shown while it is empty.
leading Widget? An icon or text before the text, inside the field.
trailing Widget? An icon or button after the text, inside the field.
obscureText bool false Hides what is typed, as for a password.
keyboardType TextInputType? The on-screen keyboard to show: numbers, an email address, a phone number.
textInputAction TextInputAction? What the on-screen keyboard's action key says and does: next, done, search.
autofillHints Iterable<String>? What the platform may fill in here from saved details: an email, a password.
inputFormatters List<TextInputFormatter>? Rules for what can be typed, such as digits only.
maxLines int 1 More than 1 makes a text area that grows to that many lines.
minLines int? How tall a text area starts, in lines. Defaults to maxLines, up to 3.
onChanged ValueChanged<String>? Told the text as it changes.
onSubmitted ValueChanged<String>? Told the text when the keyboard's action key is pressed.
validator Checks the value: gives the message to show, or null when it is right.
onSaved Told the value when the form is saved, after every field is right.
enabled False fades it and stops it taking focus or typing.
forceErrorText An error to show whatever the value: one from a server, say.
restorationId Keeps its value when the app is restored after the system closed it.
autovalidateMode AutovalidateMode.onUserInteractionIfError When it checks itself: by default once it has an error, as each change is made.

FormSendextension

Sending a Form the way people expect.

MethodWhat it is for
submit() Checks every field. When any is wrong, each shows its error and focus goes to the first on screen, where a screen reader hears it. When all are right, each field's onSaved runs. Gives whether all were right.

Source

What lands in your app. It is yours from then on: change it as you would your own code, and ashlar add will not write over it.

lib/ui/form.dart 313 lines
// Ashlar UI: Form. Copied into your app by `ashlar add form`; it is yours to change.
//
// Built on Form and FormField from Flutter's widgets layer, which keep each field's value
// and run its checks. This file draws each field as a Field with its error under it, and
// sends focus to the first field that is wrong when the form is sent.

import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';

import 'field.dart';
import 'input.dart';

/// Checks for a field's `validator`: each gives the message to show, or null when the
/// value is right. The messages are English; pass your own in other languages.
///
/// ```dart
/// validator: Validate.all([Validate.required(), Validate.email()])
/// ```
abstract final class Validate {
  /// Something must be given: not null, not blank, not an empty list, not false (for a
  /// box that must be ticked).
  static FormFieldValidator<T> required<T>([String message = 'Required.']) =>
      (v) => switch (v) {
        null || false => message,
        String s when s.trim().isEmpty => message,
        Iterable<Object?> i when i.isEmpty => message,
        _ => null,
      };

  static FormFieldValidator<String> minLength(int n, [String? message]) =>
      (v) => (v ?? '').length < n
      ? message ?? 'At least $n character${n == 1 ? '' : 's'}.'
      : null;

  static FormFieldValidator<String> maxLength(int n, [String? message]) =>
      (v) => (v ?? '').length > n
      ? message ?? 'At most $n character${n == 1 ? '' : 's'}.'
      : null;

  /// An email address, when one is given. Pair it with [required] when one must be.
  static FormFieldValidator<String> email([
    String message = 'Enter an email address.',
  ]) => pattern(_email, message);

  /// Text matching [re] in full, when any is given.
  static FormFieldValidator<String> pattern(RegExp re, String message) =>
      (v) => v == null || v.isEmpty || re.hasMatch(v) ? null : message;

  static FormFieldValidator<num> min(num least, [String? message]) =>
      (v) => v != null && v < least ? message ?? 'At least $least.' : null;

  static FormFieldValidator<num> max(num most, [String? message]) =>
      (v) => v != null && v > most ? message ?? 'At most $most.' : null;

  /// Each check in turn; the first message wins.
  static FormFieldValidator<T> all<T>(List<FormFieldValidator<T>> checks) =>
      (v) {
        for (final check in checks) {
          final message = check(v);
          if (message != null) return message;
        }
        return null;
      };

  static final _email = RegExp(r'^[^@\s]+@[^@\s]+\.[^@\s]+$');
}

/// A field of a [Form], drawn as a [Field]: its label, a description and, once it has
/// been checked, its error. [builder] makes the control from the field's state: give it
/// `field.value`, report changes with `field.didChange`, and mark it with
/// `field.hasError`.
///
/// Errors appear when the form is sent ([FormSend.submit]) and, after that, clear as each
/// one is put right.
///
/// ```dart
/// FormItem<bool>(
///   initialValue: false,
///   axis: Axis.horizontal,
///   label: Text('Accept the terms'),
///   validator: Validate.required('Accept the terms to go on.'),
///   builder: (field) => Checkbox(value: field.value!, error: field.hasError, onChanged: field.didChange),
/// )
/// ```
class FormItem<T> extends FormField<T> {
  FormItem({
    super.key,
    required Widget Function(FormItemState<T> field) builder,
    this.label,
    this.description,
    this.axis = Axis.vertical,
    super.initialValue,
    super.validator,
    super.onSaved,
    super.enabled,
    super.forceErrorText,
    super.restorationId,
    super.autovalidateMode = AutovalidateMode.onUserInteractionIfError,
  }) : super(
         builder: (state) {
           final field = state as FormItemState<T>;
           return Focus(
             focusNode: field._area,
             child: Field(
               axis: axis,
               enabled: field.widget.enabled,
               label: label,
               description: description,
               error: field.hasError ? Text(field.errorText ?? '') : null,
               child: builder(field),
             ),
           );
         },
       );

  /// Above the control, or beside it when across.
  final Widget? label;

  /// Under the control: what the field is for.
  final Widget? description;

  /// Vertical puts the label above (inputs, selects); horizontal puts the control first
  /// (checkboxes, switches).
  final Axis axis;

  @override
  FormItemState<T> createState() => FormItemState<T>();
}

/// The state of a [FormItem]: its value, its error, and [focus] to go to it.
class FormItemState<T> extends FormFieldState<T> {
  // Holds the control's focus without taking any itself, so [focus] can find it.
  final _area = FocusNode(
    canRequestFocus: false,
    skipTraversal: true,
    debugLabel: 'FormItem',
  );

  /// Moves focus to the field's control and scrolls it into view.
  void focus() {
    _area.traversalDescendants.firstOrNull?.requestFocus();
    Scrollable.ensureVisible(
      context,
      alignment: .2,
      duration: const Duration(milliseconds: 200),
    );
  }

  @override
  void dispose() {
    _area.dispose();
    super.dispose();
  }
}

/// A text field of a [Form]: an [Input] drawn as a [Field], kept in step with the form
/// when it is reset.
///
/// ```dart
/// FormText(
///   label: Text('Email'),
///   placeholder: 'you@solutech.co.ke',
///   keyboardType: TextInputType.emailAddress,
///   validator: Validate.all([Validate.required(), Validate.email()]),
///   onSaved: (v) => email = v!,
/// )
/// ```
class FormText extends FormItem<String> {
  FormText({
    super.key,
    this.controller,
    String? initialValue,
    super.label,
    super.description,
    String? placeholder,
    Widget? leading,
    Widget? trailing,
    bool obscureText = false,
    TextInputType? keyboardType,
    TextInputAction? textInputAction,
    Iterable<String>? autofillHints,
    List<TextInputFormatter>? inputFormatters,
    int maxLines = 1,
    int? minLines,
    ValueChanged<String>? onChanged,
    ValueChanged<String>? onSubmitted,
    super.validator,
    super.onSaved,
    super.enabled,
    super.forceErrorText,
    super.restorationId,
    super.autovalidateMode,
  }) : super(
         initialValue: controller?.text ?? initialValue ?? '',
         builder: (state) {
           final field = state as _FormTextState;
           return Input(
             controller: field._controller,
             placeholder: placeholder,
             leading: leading,
             trailing: trailing,
             obscureText: obscureText,
             enabled: field.widget.enabled,
             error: field.hasError,
             keyboardType: keyboardType,
             textInputAction: textInputAction,
             autofillHints: autofillHints,
             inputFormatters: inputFormatters,
             maxLines: maxLines,
             minLines: minLines,
             onChanged: (v) {
               field.didChange(v);
               onChanged?.call(v);
             },
             onSubmitted: onSubmitted,
           );
         },
       );

  /// Its text, when you want to read or set it from outside; the field makes one if not.
  final TextEditingController? controller;

  @override
  FormItemState<String> createState() => _FormTextState();
}

class _FormTextState extends FormItemState<String> {
  late final _own = widget.controller == null
      ? TextEditingController(text: widget.initialValue)
      : null;

  TextEditingController get _controller => widget.controller ?? _own!;

  @override
  FormText get widget => super.widget as FormText;

  @override
  void initState() {
    super.initState();
    _controller.addListener(_follow);
  }

  // Text set on the controller from outside becomes the field's value.
  void _follow() {
    if (_controller.text != value) super.didChange(_controller.text);
  }

  @override
  void didChange(String? value) {
    super.didChange(value);
    if (_controller.text != value) _controller.text = value ?? '';
  }

  @override
  void reset() {
    // The form's reset puts the first value back; the text follows it.
    _controller.text = widget.initialValue ?? '';
    super.reset();
  }

  @override
  void didUpdateWidget(FormText old) {
    super.didUpdateWidget(old);
    if (old.controller != widget.controller) {
      (old.controller ?? _own)?.removeListener(_follow);
      _controller.addListener(_follow);
    }
  }

  @override
  void dispose() {
    _controller.removeListener(_follow);
    _own?.dispose();
    super.dispose();
  }
}

/// Sending a [Form] the way people expect.
extension FormSend on FormState {
  /// Checks every field. When any is wrong, each shows its error and focus goes to the
  /// first on screen, where a screen reader hears it. When all are right, each field's
  /// `onSaved` runs. Gives whether all were right.
  ///
  /// ```dart
  /// Button(onPressed: () { if (form.currentState!.submit()) send(); }, child: Text('Create'))
  /// ```
  bool submit() {
    final wrong = validateGranularly();
    if (wrong.isEmpty) {
      save();
      return true;
    }
    Offset at(FormFieldState<Object?> f) {
      final box = f.context.findRenderObject() as RenderBox?;
      return box == null || !box.attached
          ? Offset.infinite
          : box.localToGlobal(Offset.zero);
    }

    // Top to bottom, then along the line: from the right in right-to-left text.
    final rtl = Directionality.maybeOf(context) == TextDirection.rtl;
    final first =
        (wrong.toList()..sort((a, b) {
              final p = at(a), q = at(b);
              if (p.dy != q.dy) return p.dy.compareTo(q.dy);
              return rtl ? q.dx.compareTo(p.dx) : p.dx.compareTo(q.dx);
            }))
            .whereType<FormItemState<Object?>>()
            .firstOrNull;
    first?.focus();
    return false;
  }
}