Skip to main content

form_ui_builder 0.5.0 copy "form_ui_builder: ^0.5.0" to clipboard
form_ui_builder: ^0.5.0 copied to clipboard

A Flutter package that helps you build dynamic and customizable user interfaces quickly and easily.

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

Requires Flutter 3.47 or later. There is no setup call: the package keeps no global state and registers nothing in your app's service locator.

Quick start #

1. Build the form definition from your schema #

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

switch (buildFormDefinitionFromSchema(schema)) {
  case FormSuccess(value: final definition):
    _show(definition);
  case FormError(:final failure):
    print(failure.message);
}

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

Both return a FormResult<FormDefinition>: a FormSuccess holding the definition, or a FormError holding a FormFailure. Read it with an exhaustive switch, as above, or with result.fold(onFailure, onSuccess). Nothing throws: a malformed schema comes back as a FormFailure naming the element that failed.

A FormDefinition is the form's structure: its elements, cross-field constraints and autoCalculations, focusOrder, and the initialValues it opens with. It never changes as the user types. The answers live separately, in FormValues.

2. Render it #

Provide the bloc, then render either widget:

BlocProvider(
  create: (_) => FormUiBuilderBloc(definition: definition),
  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, progress, the form, and a pinned submit bar.

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: 640,
        onValidationError: (message) => myBanner.show(message),
        onSubmit: (submission) => api.post(submission.toJson()),
      ),
    ),
    MyOwnSidePanel(
      onSubmitPressed: () =>
          context.read<FormUiBuilderBloc>().add(const SubmitForm()),
    ),
  ],
)

3. 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, plus ElementHidden values.
  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 are DD-MM-YYYY (17-10-2026), the way the server sends its own, and times are HH:mm (09:05).

Focus #

FormUiBuilderView creates a FocusNode for each editable element and disposes them itself. FormDefinition is plain data (focusOrder lists the focusable element ids), so you can keep a definition, reopen the same form, or hand it to several blocs, and there is nothing to dispose.

To resume a form, pass the answers too: FormUiBuilderBloc(definition: d, initialValues: saved), where saved is the FormValues from bloc.state.values. To reopen an entry stored on the server, see Add, edit and view.

Add, edit and view #

Tell the bloc what the form is open for with action. Left out, it is FormAction.add.

FormUiBuilderBloc(
  definition: definition,
  // The stored entry, keyed by database_param as FormSubmission.values sent it.
  initialValues: formValuesFromAnswers(definition, entry),
  action: FormAction.view,
)

formValuesFromAnswers gives each element its saved answer, else its element_value, and ignores an answer the form has no database_param for. It works out show/hide from the answers as the form does when it is built, so a saved choice shows the questions it opened. An answer that a rule ends up hiding is reset, so it is not submitted again. Saved totals are kept as they are.

action Fields FormUiBuilderScreen
FormAction.add Each as its is_editable and is_disable say Count, progress bar and Submit
FormAction.edit As for add As for add, but the button says "Update"
FormAction.view Every one read-only, as is_disable shows it, taking no focus No count, progress bar or button

In view, SubmitForm does nothing, so a button of your own cannot submit the entry either, and tables leave out their "Read-only" note. The app bar has one line, so pass the entry's name as title. A submitLabel you pass is used in every action.

FormAction.fromName reads an action from a string such as a route's ?action=view. Case and surrounding spaces don't matter, and null or an unknown name gives add:

FormUiBuilderBloc(
  definition: definition,
  action: FormAction.fromName(state.uri.queryParameters['action']),
)

The bloc opens a view with definition.toViewOnly(), which you can also call yourself: a definition with every element read-only and nothing to focus.

Server-backed questions #

Some questions need your server: options it lists, photos it stores, entries already made. The package makes no network calls of its own and adds no dependency. The bloc asks you, through callbacks:

FormUiBuilderBloc(
  definition: definition,
  loadOptions: (request) async {
    // Your client, base URL and auth, and what your server wants of every
    // request, such as the activity and form.
    final json = await api.get(request.url, query: {
      ...request.parameters,
      'activity': '$activityId',
      'form': '$formId',
    });
    return [
      for (final item in json['data'] as List)
        Options(
          optionId: item['id'] as int,
          optionName: LocalizedText(en: item['name'] as String),
          pValue: item['id'] as int,
        ),
    ];
  },
  imageHandler: FormImageHandler(
    capture: (context, elementId) => myCamera.takePhoto(context), // bytes, or null
    upload: (elementId, bytes) => api.uploadResource(bytes),      // the resource path
    resolveUrl: (resourcePath) => api.presignedUrl(resourcePath),
  ),
  uniqueCombinations: [
    // School 12 and class 13 already have an entry for 7001 and 3.
    UniqueCombination(elementIds: [12, 13], existing: [{12: '7001', 13: '3'}]),
  ],
)

example/lib/server_demo.dart runs all of them against a stand-in server.

Options from the server #

A choice with element_data.data_url loads its options from that URL. A dependentApi entry on a choice loads the options of the choice it names (dependent), with each parameter set to the answer of the element it names: a district's answer picks its blocks.

"element_data": {
  "options": [],
  "data_url": "/districts",
  "dependentApi": [
    { "dependent": 902, "method": "GET", "parameter": { "district": 901 }, "url": "/blocks" }
  ]
}

The form asks as it opens. An entry with an answer it names still blank asks with no parameters, as the Kotlin app did. A change to a choice that others follow clears their answers, down the chain, and asks again; a blank parameter is sent as "0". A dropdown says "Loading options…" while it waits (state.loadingOptions), an answer to an older request is dropped, and a failure goes to onValidationError, or a SnackBar. Without loadOptions, both keys are ignored.

Photos #

An ElementImageUpload asks for photos. capture takes or picks one, upload stores it and returns its resource path, and resolveUrl gives a URL to show a stored one from: the package holds no camera plugin and no endpoint. A photo shows as soon as it is taken, with a spinner until it is stored; then its path joins the answer, comma separated. Submit waits for uploads in flight, and a required question without a photo asks "Add a photo".

Without an imageHandler, or in a table cell, a photo question shows what it has and takes nothing: it is closed to entry, as is_disable closes a field.

Entries already made #

A UniqueCombination names answers that together must not repeat an entry already made. Its existing entries map each of its elements to the answer as the form stores it. Once every one of them is answered and they match an entry, their fields say "This combination of answers already exists" and submit is refused. To edit an entry, leave that entry out.

Option prompts #

An option's prompts text shows as it is picked or ticked, not as it is unticked. Pass onOptionPrompt to FormUiBuilderView or FormUiBuilderScreen to show it your own way; left out, it is a SnackBar.

Migrating from 0.1.x #

0.1.x code still compiles: every name below is kept as a deprecated alias and will be removed in a later release.

0.1.x Now
buildFormLogicBundleFromSchema(schema) buildFormDefinitionFromSchema(schema)
buildFormLogicBundle(leaves) buildFormDefinition(leaves)
Either<FormFailure, T> (fpdart) FormResult<T>. fold and getOrElse work as before
FormUiBuilderBloc(initialFormLogicBundle: b) FormUiBuilderBloc(definition: d)
state.formLogicBundle.valueMap state.values.inputs
state.formLogicBundle.visibilityMap state.values.visibility
bundle.parameterMap definition.elements
bundle.dependentOperatorMap / operatorMap definition.constraints / autoCalculations
ChildrenX FormElement, a sealed type; match on TextElement, DropdownElement, …
element.isRequired == 'true' element.isRequiredField (likewise isEditableField, …)
ElementLabel, ElementTooltip, OptionName LocalizedText
Failure FormFailure
showSnackBar, CardContainer Your own ScaffoldMessenger call and Card
setupServiceLocator() Nothing: delete the call

FormLogicBundle.fromParts(definition, values) and bundle.toDefinition() / toValues() convert between the two while you migrate.

Schema reference #

The schema is a tree. Nodes with a childrens array are containers (pages, sections); nodes without one are form elements. An ElementTable is the exception: an element whose childrens are its rows (see Tables).

Element types #

element_type is matched case-insensitively. Each type parses to its own subtype of the sealed FormElement.

element_type Dart type Renders
ElementLabel LabelElement A static heading. No value.
ElementText TextElement Text field. See input_type.
ElementDropdown DropdownElement Single choice, from element_data.options: tiles for up to 6 options, a type-to-search field beyond that.
ElementDropDownSearch DropdownElement Single choice from a type-to-search field, however few its options (isSearchable).
ElementCheckbox CheckboxElement Multi-select checkbox group. Value is comma separated.
ElementDate DateElement Date field with a picker that offers no day after today. Value is DD-MM-YYYY.
ElementTime TimeElement Time field with a picker. Value is HH:mm.
ElementHidden HiddenElement Not rendered. Its element_value is submitted, unless a show/hide rule is hiding it.
ElementImageUpload ImageElement Photos, taken and stored through the bloc's imageHandler. Value is their resource paths, comma separated. See Photos.
ElementTable TableElement A grid of headings and fields, laid out for the screen. No value of its own. See Tables.

Any other type, such as ElementButton, parses to UnknownElement and renders nothing (and logs in debug builds). Nobody can answer it, so it takes no focus, does not count towards the answered total and is never flagged. Its element_value is submitted as it came.

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 (whole numbers, not below zero unless min_val is) or mobile (10 digits). Filters the keyboard and drives validation.
min_val / max_val num? Range for number inputs on an ElementText. On an ElementDate, the ids of the dates it must come after and before; see Dates.
is_required String or bool "true" / "false". Read as isRequiredField.
is_editable String or bool "false" renders the field read-only. Read as isEditableField.
is_disable String or bool "true" shows the question but closes it to entry for now: read-only, never flagged, its element_value still submitted. Read as isDisabledField.
is_selection String or bool "true" / "false". Read as isSelectionField.
is_visible String? A show condition such as "13=1" or "6=1 AND 7=1". See Show / hide.
is_dependent bool true starts hidden.
element_data.options array option_id, option_name, pvalue per option, and an optional prompts shown as it is picked.
element_data.data_url / .dependentApi String? / array Where a choice's options load from, and the choices whose options follow its answer. See Options from the server.
elementOptionDependent object Show/hide rules. camelCase.
expression String? Cross-field rules as the form's sheet writes them, such as "sum(52+58)<=sum(45),sum(52+58)=233".
validation array Cross-field constraints, read when expression is absent or not understood.
autocalculate array Auto-calculated rollups, likewise.
style object? text_style, background_color, font_color, font_size.

Real schemas send the is_* flags as strings — "true", not true. The package reads them in any case and also accepts a JSON boolean; a missing flag reads as false. is_dependent must be a real boolean.

Elements in a table's cells leave out element_size, elementOptionDependent and is_selection, labels send "database_param": null, and some elements send element_value as a bare number. All of these read as their defaults, or as text.

Tables #

An ElementTable lists its rows in childrens; each ElementRow lists the elements in its cells, left to right. A cell is the element itself, carrying colspan and rowspan (1 when left out), and a cell spanning down from a row above is not repeated in the rows it covers — as in an HTML table:

{
  "element_id": 36,
  "element_type": "ElementTable",
  "childrens": [
    {
      "element_id": 37,
      "element_type": "ElementRow",
      "childrens": [
        { "element_id": 38, "element_type": "ElementLabel", "colspan": 2, "elementLabel": { "en": "Type of Children" } },
        { "element_id": 39, "element_type": "ElementLabel", "elementLabel": { "en": "Total" } }
      ]
    },
    {
      "element_id": 43,
      "element_type": "ElementRow",
      "childrens": [
        { "element_id": 44, "element_type": "ElementLabel", "colspan": 2, "elementLabel": { "en": "No. of enrolled children" } },
        { "element_id": 45, "element_type": "ElementText", "input_type": "number", "min_val": 5, "max_val": 500 }
      ]
    }
  ]
}

(Each element carries the rest of its usual fields; they are cut here.) The elements in the cells are elements of the form in their own right — each has its own answer, validation, auto-calculation and show/hide rules, is in definition.elements, and is submitted under its database_param. The table only says where they go, so it holds no value of its own.

The schema does not mark headings, so they are inferred: the rows at the top holding only labels are the column headings, and the columns at the left holding only labels below them are the row headings. A field's own elementLabel is not shown; its row and column headings name it.

How a table is laid out depends on the width of the form:

Width Layout
600dp and up The schema's own grid, merged cells and all: 48dp fields, 40dp from 1200dp. A grid wider than the form's column breaks out to the full width; still too wide, it scrolls sideways with the row headings pinned.
Phone, up to 4 data columns, all numbers One question per row, its fields side by side; rows under one heading (Male, Female) are grouped. The column names stay pinned at the top while the table is on screen.
Phone, up to 4 data columns, other fields One group per row, each field under its column's name.
Phone, 5 data columns or more One row at a time, picked with segmented buttons; its fields list down the screen under their column groups.

A field an auto-calculation fills in (a total) is shown read-only in a table, as the schema's web form does: it takes no focus and an empty one is not flagged as unanswered — the fields it adds up are. A field a show/hide rule hides shows an en dash in a grid and is left out on a phone. A table no field of which can change says "Read-only" once, above it. Tab and Enter move along a row, then on to the next row.

Localization #

elementLabel, elementTooltip and option_name each carry twelve language keys: en, hi, mr, gu, bn, as, or, te, kn, ta, ur, pa, parsed into a LocalizedText. The form shows each in the language of the app's locale, and falls back to en where the schema has no text in that language (null or "").

To show the questions in a language other than the app's, pass contentLocale:

FormUiBuilderScreen(contentLocale: const Locale('hi'))

contentLocale changes only the schema's text. The Material widgets (date and time pickers) follow the app's own Localizations, which is the host's to set up with flutter_localizations. LocalizedText.languageCodes lists the twelve codes, for building supportedLocales. The package's own wording, such as error messages, the required-note and "Submit", is still English. LocalizedText.forLanguage('hi') reads one text outside the form.

Cross-field constraints #

An element's expression holds its rules as the form's sheet writes them, with commas between clauses:

expression Read as
sum(13+14+15)==sum(12) 13, 14 and 15 must add up to 12
sum(27)<=sum(26) 27 may not be more than 26. Also <, >= and >
sum(108)<=min(104,105,107) 108 may not be more than the smallest of 104, 105 and 107
sum(108)>=max(104,105,107) 108 must be at least the largest of them
sum(52+58)=233 233 is filled in with 52 + 58. See Auto-calculation

The left side of a comparison holds the children, which the rule is checked on, and the right side the parents. A lone = before a function compares, like ==; before a bare id it fills that id in. Function names may be in any case, and spaces are ignored.

An element with no expression, or one the package does not understand, falls back to its validation[].values[], the same rules as the server parsed them. Those cannot say min or max, so both sides are summed:

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

Every keyword compares children to parents:

dependant In expression Children must be
equal == = parents
less than equal <= ≤ parents
less < < parents
greater than equal >= ≥ parents
greater > > parents

An unrecognised keyword is ignored rather than failing the form, and a constraint whose parents are all empty is not checked. Going over an upper bound is reported as the user types. Falling short of a lower bound waits until they leave the field and every sibling child has a value, and is always enforced on submit.

When a parent is picked, or left after typing, its children are checked again, so lowering a total below its parts flags them. Any change re-checks the children that already show an error, so a stale message clears as soon as the total is fixed. Typing never flags a field the user has not reached.

A value that breaks a constraint is kept, marked with the error, and blocks submission until fixed.

While a child is focused, the text under it says what each of its rules lets it hold: the parents less the other children.

Rule Others still blank Others answered
equal Up to 70 Must be 70
less than equal Maximum 70 Maximum 70
less Less than 70 Less than 70
greater than equal — Minimum 70
greater — More than 70

A lower bound is not stated while other children are blank: they can still make up the total. A field in two rules gets a line for each, "Up to 70 · Maximum 60". A child that a show/hide rule is hiding counts as answered. state.constraintHint.limits holds the lines as ConstraintLimits.

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

Dates

On an ElementDate, min_val and max_val are not numbers but the ids of other elements: the dates it must come strictly after and before. An end date whose min_val is its start date's id cannot fall on or before the start date. The picker offers only the days that fit, and never a day after today: a date records something that has happened. A date that stops fitting, because its start date moved later, is flagged: "Pick a date after 31/03/2026".

A rule whose children are dates compares days, so sum(91)>sum(90) says the same as a min_val of 90 and is kept once. Dates are stored as DD-MM-YYYY, the way the server sends its own; a value 0.3 saved as ISO-8601 still reads. A date rule is checked only once its date is picked; whether it must be is up to is_required.

Auto-calculation #

An expression clause sum(ids)=id fills that id in, and so does each entry of autocalculate[] when there is no expression:

{
  "operation": "sum",
  "operator": "10,11",
  "operator_result": "1"
}
operation In expression Element 1 becomes
sum sum(10+11)=1 10 + 11, a blank counting as 0
diff sum(10-11)=1 10 − 11, blank until both have a value. Between two dates it is the days from the second to the first: 10 October to 14 October is 4

A total is recomputed whenever a field it adds up changes: typed, picked, or reset because a show/hide rule hid it. A total that another total adds up passes the change on.

Show / hide #

An element's is_visible says when it is shown, as the form's sheet writes it:

is_visible Shown while
26=1 element 26 has option id 1 selected
15=3,4 element 15 has option 3 or option 4 selected
6=1 AND 7=1 both hold
6=1 OR 7=1 either holds

AND binds tighter than OR, and there are no parentheses. &&, || and | work too, in any case. A term on an element that is itself hidden does not hold. An element with an is_visible the package understands is shown exactly while it holds.

Elements without one follow elementOptionDependent on the controlling element, which 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 |.

Either way, a hidden element's answer is reset to its element_value, and it is left out of the submitted payload. Circular references are detected and stopped.

Rules are applied to the initial values too, so a form opened with a prefilled dropdown shows that dropdown's dependents straight away, and a dropdown with no answer hides its dependents whether or not they are marked is_dependent.

Option pvalues are assumed to be unique across the whole schema: option_ids repeat from one element to the next, and the pvalue is what tells them apart.

Desktop notes #

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

  • maxContentWidth (default 640) caps the column of questions, which is centred on a wide window. A table too wide for that column breaks out to the full width of the form.
  • Tab moves between questions and Enter advances to the next one. Inside a group of choice tiles, the arrow keys move between options and Space picks one.
  • Pickers (date and time dialogs, a long dropdown's list, a table cell's date, time or options) open as focus arrives on a phone or tablet, and wait for a click on a desktop, so tabbing through the form throws up nothing. Set autoOpenPickersOnFocus to true or false to choose for every platform.
  • Fonts come from your Theme's textTheme unless the schema pins style.font_size.

Example #

example/ is a full app running against a real schema of nearly 500 elements, eight tables among them, showing both the full-page and embedded layouts and what comes back from onSubmit. Its server-backed demo loads options, takes photos and refuses a visit already made, against a stand-in server.

cd example
flutter run -d windows

License #

See LICENSE.

0
likes
160
points
682
downloads

Documentation

API reference

Publisher

unverified uploader

Weekly Downloads

A Flutter package that helps you build dynamic and customizable user interfaces quickly and easily.

Repository (GitHub)
View/report issues

License

MIT (license)

Dependencies

equatable, flutter, flutter_bloc

More

Packages that depend on form_ui_builder