form_ui_builder

Renders a form UI from a server-supplied JSON schema, runs the cross-field logic and validation, and hands your app back a validated, submit-ready payload.

  • Schema in, form out — give it the JSON, it builds the widgets.
  • Cross-field logic — show/hide rules, dependent-value constraints, auto-calculated totals.
  • Validation — per-field as the user types, plus a whole-form sweep before submit.
  • Embeddable — drop the form into your own layout, or use the ready-made full page.

Install

dependencies:
  form_ui_builder: ^0.1.0

Quick start

1. Initialize

The package resolves its internals from a service locator. Call this once, before anything else:

Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized();
  await setupServiceLocator();
  runApp(const MyApp());
}

2. Build the form logic from your schema

final schema = jsonDecode(responseBody) as List<dynamic>;

final result = buildFormLogicBundleFromSchema(schema);

result.fold(
  (Failure failure) => print(failure.message),
  (FormLogicBundle bundle) => _show(bundle),
);

buildFormLogicBundleFromSchema walks every page and section down to the leaf elements. If you have already flattened the schema yourself, pass the leaf list to buildFormLogicBundle instead.

Both return an Either<Failure, FormLogicBundle> (fpdart). Nothing throws: a malformed schema comes back as a Failure naming the element that failed.

3. Render it

Provide the bloc, then render either widget:

BlocProvider(
  create: (_) => FormUiBuilderBloc(initialFormLogicBundle: bundle),
  child: FormUiBuilderScreen(
    title: 'Household survey',
    onSubmit: (FormSubmission submission) {
      // submission.values is Map<String, String>, keyed by database_param
      api.post(submission.toJson());
    },
  ),
)

FormUiBuilderScreen is a full page: app bar, form, submit button.

To place the form inside your own layout — a pane, a dialog, a split view — use FormUiBuilderView, which supplies no chrome of its own. Drive submission by dispatching SubmitForm from your own button:

Row(
  children: [
    Expanded(
      child: FormUiBuilderView(
        maxContentWidth: 720,
        onValidationError: (message) => myBanner.show(message),
        onSubmit: (submission) => api.post(submission.toJson()),
      ),
    ),
    MyOwnSidePanel(
      onSubmitPressed: () =>
          context.read<FormUiBuilderBloc>().add(const SubmitForm()),
    ),
  ],
)

4. Read the result

onSubmit fires only after the whole form validates. Until then a SubmitForm marks the offending fields, moves focus to the first one, and reports through onValidationError.

class FormSubmission {
  /// Answers keyed by `database_param`. Visible fields only.
  final Map<String, String> values;

  /// Every element, hidden ones included.
  final List<FormFieldResult> fields;

  Map<String, dynamic> toJson();
}

Values are always strings: multi-select checkboxes are comma separated, dates and times are ISO-8601.

Who owns the FocusNodes

FormLogicBundle holds a FocusNode per editable element.

  • Pass a bundle you built to FormUiBuilderBloc(initialFormLogicBundle: ...) and you own those nodes — dispose them when you discard the bundle. The bloc will not.
  • Let the bloc build the bundle itself (dispatch GenerateParameterMap) and it owns and disposes them.

This is what lets you open the same form twice without a "FocusNode used after being disposed".

Schema reference

The schema is a tree. Nodes with a childrens array are containers (pages, sections); nodes without one are form elements.

Element types

element_type is matched case-insensitively.

element_type Renders
ElementLabel A static heading. No value.
ElementText Text field. See input_type.
ElementDropdown Single-select dropdown, from element_data.options.
ElementCheckbox Multi-select checkbox group. Value is comma separated.
ElementDate Date field with a picker. Value is ISO-8601.
ElementTime Time field with a picker. Value is ISO-8601.
ElementHidden Not rendered, not submitted.

Any other type renders nothing (and logs in debug builds).

Element fields

Key Type Meaning
element_id int Unique id. Every map is keyed by this.
element_type String See above.
database_param String Backend field name — the key in FormSubmission.values.
elementLabel object Label, per language. Note the camelCase key.
elementTooltip object Helper text, per language. camelCase.
element_value String? Initial value.
input_type String text, number or mobile. Drives per-keystroke validation.
min_val / max_val num? Range for number inputs.
is_required String "true" / "false".
is_editable String "false" renders the field read-only.
is_disable, is_selection, is_visible String "true" / "false".
is_dependent bool The one real boolean. true starts hidden.
element_data.options array option_id, option_name, p_value per option.
elementOptionDependent object Show/hide rules. camelCase.
validation array Cross-field constraints.
autocalculate array Auto-calculated rollups.
style object? text_style, background_color, font_color, font_size.

The boolean-ish fields are strings, not JSON booleans"true", not true. Only is_dependent is a real boolean.

Localization

elementLabel, elementTooltip and option_name each carry twelve language keys: en, hi, mr, gu, bn, as, or, te, kn, ta, ur, pa. They are parsed, but the widgets currently render en only, and the package's own error messages are English.

Cross-field constraints

Each entry of validation[].values[] declares one constraint:

{
  "dependant": "equal",
  "dependent_operator": "10,11",
  "dependent_result": "1"
}

Read as: elements 10 and 11 (the children) must relate to element 1 (the parent) by equal — they must sum to it. dependant may be equal, less, greater, less than equal or greater than equal. An unrecognised keyword is ignored rather than failing the form.

Blank or unparseable ids are skipped, so "dependent_operator": "" is fine.

Auto-calculation

Each entry of autocalculate[] rolls children up into a parent:

{
  "operation": "sum",
  "operator": "10,11",
  "operator_result": "1"
}

Element 1 becomes the sum of 10 and 11, recomputed as they change. Only sum is implemented.

Show / hide

elementOptionDependent maps a condition to the ids it governs:

{ "elementOptionDependent": { "42": "10,11", "7 = 3": "12" } }

A bare id is "this option is selected"; a = b compares element a's selected option to option id b. Conditions may be OR-ed with |. When a condition turns false its elements are hidden and reset to their element_value, and they are left out of the submitted payload. Circular references are detected and stopped.

Desktop notes

The package is desktop-ready and the example runs on Windows.

  • maxContentWidth (default 720) stops fields stretching across a wide window.
  • Tab traversal works; Enter advances to the next field.
  • Dropdowns and date fields do not pop open on focus by default — set autoOpenPickersOnFocus: true for the touch-friendly behavior.
  • Fonts come from your Theme's textTheme unless the schema pins style.font_size.

Example

example/ is a full app running against a real ~5400-line schema, showing both the full-page and embedded layouts and what comes back from onSubmit.

cd example
flutter run -d windows

License

See LICENSE.