smart_form_fields 0.2.3 copy "smart_form_fields: ^0.2.3" to clipboard
smart_form_fields: ^0.2.3 copied to clipboard

Behavior-first Flutter forms with validation and first-error navigation.

smart_form_fields #

A behavior-first Flutter form package for field registration, synchronous and asynchronous validation, value collection, and navigation to the first invalid field.

The 0.1 release provides a tested foundation for production Flutter forms. The public API remains pre-1.0 and may evolve through documented minor releases.

Installation #

Add the package to your application:

flutter pub add smart_form_fields

Then import its single public library:

import 'package:smart_form_fields/smart_form_fields.dart';

Quick start #

final formKey = SmartFormKey();

SmartForm(
  key: formKey,
  children: [
    SmartEmailField(
      name: 'email',
      required: true,
      decoration: const InputDecoration(labelText: 'Email'),
    ),
  ],
);

final result = await formKey.validate();

if (result.isValid) {
  print(result.values['email']);
}

Validation timing #

Fields validate when they lose focus by default. Entering text clears an old field or server error, but does not show a new validator error while the user is still typing. Calling validate()—for example from a submit button—always validates every enabled field immediately.

To validate only after the user taps a submit button, set the mode once on the form. Every descendant field inherits it:

SmartForm(
  controller: formController,
  autovalidateMode: AutovalidateMode.disabled,
  children: [
    SmartTextField(name: 'first_name', validators: [...]),
    SmartEmailField(name: 'email', required: true),
  ],
);

final result = await formController.validate();

An individual field can still override the form default with its own autovalidateMode. SmartJsonForm provides the same form-level option, while the snake_case field property autovalidate_mode remains available for a JSON field override.

Dropdown menus temporarily move focus while opening. To avoid showing a required error or interrupting the first tap, dropdowns interpret the default onUnfocus mode as validation after selection or after dismissing the menu. Dismissal also releases the dropdown's restored focus, so the next control responds to its first tap.

Change-time validation remains available per field:

SmartTextField(
  name: 'username',
  autovalidateMode: AutovalidateMode.onUserInteraction,
  asyncValidationDebounce: const Duration(milliseconds: 400),
  asyncValidators: [...],
);

Built-in validators include required, email, exact/minimum/maximum length, pattern, number, min, and max. Every validator accepts a message override, and optional fields can omit required.

The first release will provide:

  • automatic field registration and lifecycle handling;
  • synchronous and race-safe asynchronous validation;
  • scrolling and focusing the first invalid field;
  • immutable validation results and value snapshots;
  • custom generic fields and common Material field wrappers;
  • opt-in forms generated from API JSON schemas;
  • configurable validation messages;
  • server-side field error injection.

See PLAN.md for the implementation phases, API decisions, test matrix, and release gates.

Form behavior theme #

Use SmartFormTheme to share scrolling, focus, and error-animation defaults across multiple forms. Values set directly on SmartForm take precedence.

SmartFormTheme(
  data: const SmartFormThemeData(
    errorAnimation: SmartErrorAnimation.fade,
    scrollToFirstError: true,
  ),
  child: SmartForm(
    children: [...],
  ),
);

Validation-message localization remains application-owned. Pass the desired message to a validator, for example SmartValidators.required(message: 'Required').

Form access and controller lifecycle #

Use SmartFormKey for local, key-based access or SmartFormController when a controller fits the owning widget better. Both expose validation, current values, value patching, reset, focus/scroll commands, and server errors.

class RegistrationState extends State<Registration> {
  final formController = SmartFormController();

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

  @override
  Widget build(BuildContext context) {
    return SmartForm(
      controller: formController,
      children: const [...],
    );
  }
}

A caller-owned controller is not disposed by SmartForm. One controller can be attached to only one mounted form at a time, and commands require it to be attached.

Async validation #

Async validators return a Future<String?>. Explicit form validation always waits for them. Obsolete results are discarded when a value changes while an older validation request is still running.

SmartEmailField(
  name: 'email',
  asyncValidationDebounce: const Duration(milliseconds: 400),
  asyncValidators: [
    (value) async {
      final available = await repository.isEmailAvailable(value);
      return available ? null : 'Email is already registered';
    },
  ],
);

The debounce applies to automatic validation only. A submit-triggered validate() call starts immediately.

Cross-field dependencies #

Built-in dependent validators declare the source field explicitly. After a dependent field has been validated once, changing its source automatically revalidates it. Before its first validation, dependency changes do not expose premature errors.

SmartPasswordField(
  name: 'confirm_password',
  validators: [
    SmartValidators.matchesField<String>(
      'password',
      message: 'Passwords do not match',
    ),
  ],
);

SmartTextField(
  name: 'company_name',
  validators: [
    SmartValidators.requiredWhen<String>(
      field: 'account_type',
      equals: AccountType.business,
      message: 'Company name is required',
    ),
  ],
);

Create application-specific rules with a read-only value snapshot. Every field read from the context should be listed in dependsOn so changes can trigger revalidation:

SmartValidators.dependent<String>(
  dependsOn: const ['country'],
  validator: (value, context) {
    final country = context.valueOf<String>('country');
    return isCityAllowed(country, value) ? null : 'Invalid city';
  },
);

Async dependent validation uses the same contract and retains stale-result protection:

SmartAsyncValidators.dependent<String>(
  dependsOn: const ['country'],
  validator: (value, context) async {
    return repository.validateCity(
      country: context.valueOf<String>('country'),
      city: value,
    );
  },
);

Unknown dependency names and dependency cycles fail with descriptive errors before explicit validation runs. patchValue applies all values before revalidating dependents, so validators see the final snapshot.

Keyboard and focus behavior #

SmartForm observes keyboard visibility through Flutter view-inset changes. By default, it unfocuses its active field when the keyboard becomes hidden and when the user taps outside that field, including blank space inside the form. Only focus owned by that form is changed.

Moving directly between fields does not create an intermediate unfocus, and a keyboard height change while switching input types is not treated as dismissal.

SmartForm(
  dismissKeyboardOnTapOutside: true,
  unfocusOnKeyboardDismiss: true,
  onKeyboardVisibilityChanged: (isVisible) {
    debugPrint('Keyboard visible: $isVisible');
  },
  children: [...],
);

Set either behavior flag to false when a screen manages focus itself. The same options are available on SmartJsonForm.

Custom fields #

Compose SmartFormField<T> when the built-in Material wrappers do not match the desired interaction. This is also the way to use a bottom sheet, dialog, or custom picker instead of SmartDropdownField<T>.

SmartFormField<String>(
  name: 'country',
  validators: [SmartValidators.required(message: 'Choose a country')],
  builder: (context, field) {
    return ListTile(
      title: Text(field.value ?? 'Choose country'),
      subtitle: field.errorText == null ? null : Text(field.errorText!),
      onTap: field.enabled
          ? () async {
              final value = await showCountryBottomSheet(context);
              if (value != null) field.didChange(value);
            }
          : null,
    );
  },
);

Render field.errorText in a custom widget and call field.didChange whenever its value changes. Use field.isValidating when the UI should expose async validation progress.

Forms from API JSON #

Use SmartJsonForm.fromJson when an API returns a form definition. The JSON layer builds the same smart field widgets, so values, validation timing, async race handling, reset, server errors, and first-error navigation behave exactly like a widget-authored form.

final schema = jsonDecode(response.body) as Map<String, Object?>;
final controller = SmartFormController();

SmartJsonForm.fromJson(
  json: schema,
  controller: controller,
  asyncValidators: {
    // JSON references this executable validator by name.
    'emailAvailable': (value) async {
      final available = await repository.isEmailAvailable(value as String?);
      return available ? null : 'Email is already registered';
    },
  },
);

Example API response:

{
  "scroll_to_first_error": true,
  "error_animation": "fade",
  "fields": [
    {
      "type": "email",
      "name": "email",
      "label_text": "Email",
      "required": true,
      "required_message": "Email is required",
      "async_validators": ["emailAvailable"]
    },
    {
      "type": "password",
      "name": "password",
      "label_text": "Password",
      "min_length": 8,
      "min_length_message": "Use at least 8 characters"
    },
    {
      "type": "dropdown",
      "name": "country",
      "label_text": "Country",
      "options": [
        {"value": "md", "label": "Moldova"},
        {"value": "ro", "label": "Romania"}
      ]
    }
  ]
}

Built-in field types are text, email, phone, password, date, and dropdown. Validator objects support required, email, length, min_length, max_length, pattern, number, min, and max; numeric or length limits use a value property.

Dependent validator objects use snake_case types and properties:

{
  "type": "matches_field",
  "field": "password",
  "message": "Passwords do not match"
}
{
  "type": "required_when",
  "field": "account_type",
  "equals": "business",
  "message": "Company name is required"
}

Named async validators can declare JSON-owned dependency metadata while the application still supplies the executable Dart callback:

"async_validators": [
  {
    "name": "username_available",
    "depends_on": ["account_type"]
  }
]

All JSON property names use snake_case, including scroll_to_first_error, label_text, initial_value, required_message, and async_validators. Autovalidation values also use names such as on_unfocus and on_user_interaction.

JSON cannot contain executable Dart code. Register named async validators, customValidatorBuilders, or customFieldBuilders in the application for API-specific behavior. Unknown field and validator types fail explicitly instead of silently rendering an incomplete form.

Server errors and value updates #

Backend errors can be applied after a request. The next value change clears the server error for that field.

await formController.setErrors(
  {
    'email': 'The server rejected this email',
    'phone': 'The server could not verify this number',
  },
  scrollToFirstError: true,
);

formController.patchValue({
  'email': 'person@example.com',
  'country': 'Moldova',
});

patchValue validates all field names before changing any value. Unknown names throw instead of leaving the form partially updated.

Disabled fields and navigation #

Disabled fields remain registered and appear in values, but validation skips them. On failed validation, SmartForm navigates to the first invalid enabled field in current widget order. Scrolling and focusing are best-effort and never change the returned SmartFormResult.

Set scrollToFirstError or focusFirstError to false when the surrounding screen owns navigation. Custom fields that cannot accept keyboard focus still scroll into view. Reduced-motion platform settings suppress error animation. When form validation navigates to an invalid field, its error animation starts only after scrolling and focus navigation finish.

Validation result #

validate() returns an immutable snapshot. Values preserve their field types, so text fields return String?, date fields return DateTime?, and generic fields return their declared type.

final result = await formController.validate();
if (!result.isValid) return;

final email = result.values['email'] as String?;
final birthDate = result.values['birthDate'] as DateTime?;

Example application #

The example directory contains three Material 3 screens: a complete registration flow, a snake_case JSON/API form, and an imperative controller playground. Together they demonstrate reusable and custom fields, sync/async validation, bottom-sheet selection, value updates, dynamic and disabled fields, reset, server errors, focus/scroll commands, and first-error navigation.

cd example
flutter run

Current status #

The package foundation, form key/controller API, immutable result model, registry, generic custom field, text field, core sync/async validation, built-in validators, error animations, and first-error navigation are implemented. The initial reusable field set now includes text, email, password, phone, date, and generic dropdown fields. Shared form behavior can be configured with SmartFormTheme, and SmartJsonForm can build the same fields from API schemas. Cross-field sync and async validators use explicit dependency metadata and read-only form snapshots. Bundled validation-message localization is intentionally out of scope.

0
likes
150
points
587
downloads

Documentation

API reference

Publisher

verified publisherpinz.dev

Weekly Downloads

Behavior-first Flutter forms with validation and first-error navigation.

License

MIT (license)

Dependencies

flutter

More

Packages that depend on smart_form_fields