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: truefor the touch-friendly behavior. - Fonts come from your
Theme'stextThemeunless the schema pinsstyle.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.