form_ui_builder 0.5.0
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
autoOpenPickersOnFocustotrueorfalseto choose for every platform. - Fonts come from your
Theme'stextThemeunless the schema pinsstyle.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.