json_form_engine 0.3.1
json_form_engine: ^0.3.1 copied to clipboard
JSON-driven dynamic form builder for Flutter: validated, conditional, multi-step forms with 50+ field types, theming, and localization.
json_form_engine #
JSON-driven dynamic form builder for Flutter. Generate complete, validated,
conditional, multi-step forms from JSON — with a powerful controller API,
50+ field types, built-in image/camera/file pickers, theming, localization
(en/hi/ar/es/fr/de + custom), and no third-party dependencies (only
Flutter's official image_picker / file_selector plugins).
Why #
- Server-driven UI — change forms without shipping an app update.
- One controller for everything — values, validation, focus, visibility, runtime add/remove of fields.
- Performance-first — every field listens to its own
ValueNotifiers; typing in one field never rebuilds another. Fields render lazily viaListView.builder, so 1000+ field forms stay smooth. - Extensible by design — every renderer, validator and field type can be overridden or added at runtime (Factory + Strategy patterns).
Quick start #
import 'package:json_form_engine/json_form_engine.dart';
final controller = DynamicFormController();
DynamicForm(
controller: controller,
json: const {
'id': 'registration',
'fields': [
{'type': 'text', 'id': 'name', 'label': 'Full Name',
'validators': ['required']},
{'type': 'email', 'id': 'email', 'label': 'Email',
'validators': ['required', 'email']},
{'type': 'dropdown', 'id': 'country', 'label': 'Country',
'items': [
{'label': 'India', 'value': 'IN'},
{'label': 'USA', 'value': 'US'},
]},
],
},
showSubmitButton: true,
onSubmit: (data) => print(data),
onChanged: (data) => print(data),
)
json accepts a JSON string or a decoded map — local asset, remote
API response, or built at runtime. Passing a new value re-parses the form and
preserves values of surviving field ids (runtime JSON changes).
Field types #
| Category | Types |
|---|---|
| Text | text, textarea, password, email, number, decimal, phone, url, search, otp, pin, readOnly |
| Date & time | date, time, datetime (stored as ISO-8601 strings) |
| Selection | dropdown, multiselect, checkbox, checkboxGroup, radio, radioGroup, switch, chips, toggleButtons, segmented, country, state, city, autocomplete, typeahead |
| Numeric | slider, rangeSlider, rating, stepper |
| Misc | colorPicker, hidden, label, divider, spacer, sectionHeader, expansion, group, custom |
| Media (built-in) | image, camera, file |
| Pluggable | signature, qrScanner, barcodeScanner, richText, markdown, htmlEditor |
Pluggable types keep the core dependency-free: the JSON schema, controller and validation all work out of the box, and you register the widget backed by the plugin of your choice once at startup:
FieldFactory.register(FieldType.signature, (context, field, controller) {
return MySignaturePad(
onDone: (bytes) => controller.setValue(field.id, bytes),
);
});
Custom JSON types work the same way:
FieldFactory.registerCustom('map_picker', (context, field, controller) => ...);
// JSON: {"type": "custom", "id": "loc", "customType": "map_picker"}
Media fields: image, camera & file (built-in) #
Gallery/camera image picking (with thumbnail previews) and document picking work straight from JSON — no adapter registration needed:
{"fields": [
{"type": "image", "id": "profilePhoto", "label": "Profile Photo",
"source": "both", "imageQuality": 80},
{"type": "image", "id": "gallery", "label": "Photos",
"source": "gallery", "multiple": true, "maxImages": 4},
{"type": "camera", "id": "selfie", "label": "Selfie",
"preferredCamera": "front"},
{"type": "file", "id": "resume", "label": "Resume",
"extensions": ["pdf", "docx"], "validators": ["required"]}
]}
image—source:"gallery","camera"or"both"(default; shows a Gallery/Camera bottom sheet). Extras:multiple,maxImages,imageQuality(0–100),maxWidth/maxHeight,preferredCamera("front"/"rear"),video: true(pick/record a video,maxDurationSeconds),previewSize.camera— camera-only shorthand forimage.file— extras:multiple,maxFiles,extensions(e.g.["pdf", "docx"]),mimeTypes.
Stored value: the picked file path as a String (a List<String>
with "multiple": true) — read it with controller.getValue(id) and upload
however you like. required validation, conditions, theming and edit mode
all work like any other field.
Powered by Flutter's official image_picker and file_selector plugins.
Platform setup (only what those plugins need):
- iOS — add to
ios/Runner/Info.plist:NSPhotoLibraryUsageDescription,NSCameraUsageDescriptionand (for video)NSMicrophoneUsageDescription. - Android — nothing; the photo picker and camera intents need no manifest permissions.
Want a different picking flow (cropper, custom permission UX)? Swap the service — the field UI stays:
MediaPickerAdapter.instance = MyCroppingPickerAdapter();
…or replace the whole widget via FieldFactory.register(FieldType.image, ...).
Common field properties #
id, key, name, label, hint, helperText, initialValue,
defaultValue, required, enabled, readOnly, visible, validators,
maxLength, minLength, regex, prefixIcon, suffixIcon,
keyboardType, textInputAction, autofocus, obscureText, padding,
margin, width, height — plus type-specific extras (min, max,
divisions, rows, length, multiple, colors, expanded, …) available
via field.ex<T>('name').
Controller #
final controller = DynamicFormController(locale: 'hi');
controller.getValue('name');
controller.setValue('name', 'John');
controller.clearField('name');
controller.reset();
controller.validate(); // bool
controller.submit(); // Map? (null when invalid)
controller.getFormData();
controller.setFormData({'name': 'A'});
controller.addField(FieldConfig.fromJson({...}), index: 2);
controller.removeField('name');
controller.hideField('state'); controller.showField('state');
controller.enableField('x'); controller.disableField('x');
controller.focusField('email'); controller.unfocus();
controller.getErrors(); controller.clearErrors();
final cancel = controller.listen('name', (value) => ...); cancel();
controller.dispose();
Typed helpers: getString, getInt, getDouble, getBool, getList,
hasErrors. Dirty state: isDirty, dirty (ValueListenable),
markClean().
Validation (JSON-configurable) #
{
"type": "password", "id": "password",
"validators": [
"required",
{"type": "passwordStrength"},
{"type": "minLength", "value": 8, "message": "Too short"}
]
}
Built-in: required, email, phone, url, number, decimal, min,
max, minLength, maxLength, regex, matchField, passwordStrength.
Custom validators, usable from JSON by name:
ValidatorRegistry.register('gstin', (cfg) => CustomValidator(cfg,
(value, formData) => isGstin('$value') ? null : 'Invalid GSTIN'));
Conditional logic #
{"type": "dropdown", "id": "state",
"visibleWhen": {"field": "country", "operator": "equals", "value": "IN"}}
{"type": "text", "id": "license",
"enabledWhen": {"field": "age", "operator": "greaterThanOrEqual", "value": 18}}
{"type": "text", "id": "gst",
"requiredWhen": {"and": [
{"field": "country", "operator": "equals", "value": "IN"},
{"field": "business", "operator": "equals", "value": true}
]}}
Operators: equals, notEquals, greaterThan, greaterThanOrEqual,
lessThan, lessThanOrEqual, contains, startsWith, endsWith,
isEmpty, isNotEmpty, in — composable with and, or, not.
Dynamic / async options #
DynamicFormController(
optionsLoader: (fieldId, formData) async {
final res = await api.get('/options/$fieldId?country=${formData['country']}');
return [for (final o in res) OptionItem(label: o['name'], value: o['id'])];
},
)
Fields opt in with "optionsUrl": "..." (or empty options on selection
types). Add "dependsOn": ["country"] to clear + reload when a parent field
changes — the classic country → state → city chain.
Multi-step / wizard forms #
Use steps instead of fields; each step validates before advancing:
{"steps": [
{"title": "Account", "fields": [ ... ]},
{"title": "Profile", "fields": [ ... ]}
]}
Nested sections: {"type": "expansion", "fields": [...]} and
{"type": "group", "fields": [...]} — children keep flat ids, so the whole
controller API works on them.
Field UI styles (JSON-driven) #
Five built-in input looks, switchable from JSON at three levels — app theme default, form root, and per field (most specific wins):
{
"style": {"variant": "rounded", "borderColor": "#3F51B5"},
"fields": [
{"type": "text", "id": "name", "label": "Name"},
{"type": "email", "id": "email",
"style": {"variant": "filled", "fillColor": "#EEF0FB",
"textStyle": {"fontSize": 16, "fontWeight": "w600"}}}
]
}
Variants: outlined, rounded (pill), filled, underline, none.
Per-style knobs: borderRadius, fillColor, borderColor,
focusedBorderColor, borderWidth, dense, contentPadding,
labelBehavior (auto/always/never), textStyle / labelStyle /
hintStyle (fontSize, color, fontWeight, italic, letterSpacing).
Colors are #RRGGBB / #AARRGGBB. The field-level key can be style or
decoration — both are accepted. App-wide default:
DynamicFormThemeData(defaultFieldStyle: ...). Because style lives in the
JSON, changing it at runtime restyles the form while preserving values.
Edit mode (prefilled forms) #
Open the same JSON form prefilled with an existing record — three ways:
// 1. From code — wins over everything:
DynamicForm(controller: c, json: formJson,
initialData: {'name': 'Rupesh', 'country': 'IN'})
// 2. From the JSON itself — server ships definition + record together:
// {"fields": [...], "data": {"name": "Rupesh", "country": "IN"}}
// 3. Programmatically, after an async fetch:
controller.setFormData(record, asInitial: true);
Edit-mode semantics are handled correctly everywhere:
- The prefilled form starts clean — the discard guard only triggers after the user actually edits something.
- Typing a field back to its record value makes the form clean again (deep-compared baseline).
controller.reset()restores the record, not the field defaults.- Runtime JSON changes preserve both the values and the dirty baseline.
- Passing a new
initialDatamap instance re-prefills at runtime (switching the edit target).
Dirty tracking & discard guard #
The controller tracks unsaved changes (deep-compared against the last clean
baseline): controller.isDirty, the listenable controller.dirty, and
controller.markClean() (called automatically on attach, reset() and a
successful submit()).
Enable the back-navigation guard from JSON — when the user edited something and navigates back, a confirmation dialog asks before discarding:
{
"confirmDiscard": true,
"discardTitle": "Discard changes?",
"discardMessage": "You have unsaved changes.",
"fields": [ ... ]
}
"confirmDiscard": false(or omitted) disables it — fully dynamic.- Code override wins:
DynamicForm(confirmDiscard: true/false, ...). - Title/message fall back to the localized defaults (all 6 locales).
- Fully custom dialog:
DynamicFormThemeData(discardDialogBuilder: (context) async => ...)— returntrueto leave,false/nullto stay.
Theming #
DynamicFormTheme(
data: DynamicFormThemeData(
fieldSpacing: 20,
decorationBuilder: (context, field, decoration) =>
decoration.copyWith(border: const OutlineInputBorder()),
errorBuilder: (context, message) => MyErrorBanner(message),
loadingBuilder: (context) => const MySpinner(),
),
child: DynamicForm(...),
)
Material 3 by default; the form inherits your app ThemeData
(light/dark/high-contrast follow automatically). Every renderer can be
replaced wholesale via FieldFactory.register.
Localization #
Built-in message locales: en, hi, ar (RTL), es, fr, de.
DynamicFormController(locale: 'ar');
FormLocalizations.addTranslations('ta', {'required': 'இது தேவை'});
State management #
The package is self-contained (plain ChangeNotifier + ValueNotifier), so
it plugs into anything:
// Provider
ChangeNotifierProvider(create: (_) => DynamicFormController());
// Riverpod
final formControllerProvider =
Provider.autoDispose((ref) {
final c = DynamicFormController();
ref.onDispose(c.dispose);
return c;
});
// Bloc — forward changes into your bloc
controller.onChanged = (id, value, data) =>
context.read<FormBloc>().add(FormFieldChanged(id, value));
// GetX
class FormCtrl extends GetxController {
final form = DynamicFormController();
@override void onClose() { form.dispose(); super.onClose(); }
}
Accessibility #
Semantic labels from JSON labels, full keyboard navigation
(textInputAction: next + focus API), screen-reader friendly Material
widgets, and high-contrast support via your app theme.
Example #
See example/lib/main.dart for a complete
registration demo: conditional state dropdown fed by a simulated API,
age-gated license field, chips, sliders, rating, color picker, image /
camera / file pickers and multi-rule validation.
License #
MIT © Rupesh Rajak