FormStack
A cross-platform alternative to Apple ResearchKit for Flutter. Build dynamic forms, surveys, and research questionnaires from Dart objects or JSON -- on iOS, Android, Web, macOS, Windows, and Linux.
35 input types, 9 step types, 35+ validators, conditional navigation, structured consent flows, answer review, progress tracking, and full extensibility. Every ResearchKit UI pattern can be reproduced with FormStack.
Why FormStack over ResearchKit?
| ResearchKit | FormStack | |
|---|---|---|
| Platforms | iOS only | iOS, Android, Web, macOS, Windows, Linux |
| Language | Swift/Objective-C | Dart (Flutter) |
| Form source | Code only | Dart objects or JSON |
| Input types | ~15 answer formats | 35 input types |
| Validators | Basic (length, regex) | 35+ built-in + custom subclassing |
| Extensibility | Subclass ORKStep | Subclass FormStep, BaseStepView, ResultFormat, RelevantCondition |
| Results | ORKTaskResult hierarchy | TaskResult/StepResult with timestamps + JSON export |
| Consent flow | ORKConsentDocument | ConsentStep with sections + agreement |
| Review step | ORKReviewStep | ReviewStep with formatted answer display |
| Progress bar | Built-in | Built-in with step counter |
Screenshots
ResearchKit to FormStack Migration
If you're migrating from Apple ResearchKit, here's how the concepts map:
Step Types
| ResearchKit (iOS) | FormStack (Flutter) | Notes |
|---|---|---|
ORKInstructionStep |
InstructionStep |
Supports Lottie animations, static images, and video URLs |
ORKQuestionStep |
QuestionStep |
35 input types vs ResearchKit's ~15 |
ORKFormStep |
NestedStep |
Multiple fields on one screen with cross-field validation |
ORKCompletionStep |
CompletionStep |
Loading/success/error Lottie animations, async callbacks |
ORKConsentDocument + ORKVisualConsentStep |
ConsentStep |
Expandable sections with 8 predefined types |
ORKConsentReviewStep |
ConsentStep (agreement checkbox) |
Built into ConsentStep |
ORKReviewStep |
ReviewStep |
Displays all answers before submission |
ORKSignatureStep |
QuestionStep(inputType: InputType.signature) |
Canvas drawing, returns base64 PNG |
ORKWebViewStep |
DisplayStep(displayStepType: DisplayStepType.web) |
WebView content |
ORKNavigableOrderedTask |
FormStack.api().form(steps: [...]) |
Conditional navigation via relevantConditions |
Answer Formats
| ResearchKit (iOS) | FormStack (Flutter) | Code |
|---|---|---|
ORKTextAnswerFormat |
InputType.text |
QuestionStep(inputType: InputType.text, numberOfLines: 3) |
ORKNumericAnswerFormat |
InputType.number |
QuestionStep(inputType: InputType.number) |
ORKScaleAnswerFormat |
InputType.slider |
QuestionStep(inputType: InputType.slider, minValue: 0, maxValue: 10) |
ORKBooleanAnswerFormat |
InputType.boolean |
QuestionStep(inputType: InputType.boolean) |
ORKTextChoiceAnswerFormat (single) |
InputType.singleChoice |
QuestionStep(inputType: InputType.singleChoice, options: [...]) |
ORKTextChoiceAnswerFormat (multi) |
InputType.multipleChoice |
QuestionStep(inputType: InputType.multipleChoice, options: [...]) |
ORKImageChoiceAnswerFormat |
InputType.imageChoice |
QuestionStep(inputType: InputType.imageChoice, options: [...]) |
ORKValuePickerAnswerFormat |
InputType.dropdown |
QuestionStep(inputType: InputType.dropdown, options: [...]) |
ORKDateAnswerFormat |
InputType.date |
QuestionStep(inputType: InputType.date) |
ORKTimeOfDayAnswerFormat |
InputType.time |
QuestionStep(inputType: InputType.time) |
ORKLocationAnswerFormat |
InputType.mapLocation |
QuestionStep(inputType: InputType.mapLocation) |
Navigation Rules
| ResearchKit (iOS) | FormStack (Flutter) | Code |
|---|---|---|
ORKPredicateStepNavigationRule |
ExpressionRelevant |
ExpressionRelevant(identifier: GenericIdentifier(id: "step"), expression: "IN value") |
ORKDirectStepNavigationRule |
ExpressionRelevant with FOR_ALL |
ExpressionRelevant(identifier: ..., expression: "FOR_ALL") |
| Custom delegate | DynamicConditionalRelevant |
DynamicConditionalRelevant(identifier: ..., isValidCallBack: (result) => ...) |
Results
| ResearchKit (iOS) | FormStack (Flutter) | Code |
|---|---|---|
ORKTaskResult |
TaskResult |
FormStack.api().getTaskResult() |
ORKStepResult |
StepResult |
Includes startTime, endTime, duration, value |
result.startDate / endDate |
step.startTime / endTime |
Automatic timestamp recording |
| JSON serialization | exportAsJson() |
FormStack.api().exportAsJson(formName: "myForm") |
Quick Migration Example
ResearchKit (Swift):
let step1 = ORKInstructionStep(identifier: "intro")
step1.title = "Welcome"
step1.text = "This survey takes 5 minutes"
let step2 = ORKQuestionStep(identifier: "name")
step2.title = "Your Name"
step2.answerFormat = ORKTextAnswerFormat(maximumLength: 50)
let step3 = ORKQuestionStep(identifier: "satisfaction")
step3.title = "How satisfied are you?"
step3.answerFormat = ORKScaleAnswerFormat(maximumValue: 10, minimumValue: 0, defaultValue: 5, step: 1)
let task = ORKOrderedTask(identifier: "survey", steps: [step1, step2, step3])
let taskVC = ORKTaskViewController(task: task, taskRun: nil)
FormStack (Dart) - same UI, all platforms:
FormStack.api().form(steps: [
InstructionStep(
id: GenericIdentifier(id: "intro"),
title: "Welcome",
text: "This survey takes 5 minutes",
),
QuestionStep(
id: GenericIdentifier(id: "name"),
title: "Your Name",
inputType: InputType.name,
lengthLimit: 50,
),
QuestionStep(
id: GenericIdentifier(id: "satisfaction"),
title: "How satisfied are you?",
inputType: InputType.slider,
minValue: 0,
maxValue: 10,
stepValue: 1,
defaultValue: 5,
),
]);
// Render in any Flutter widget
Scaffold(body: FormStack.api().render());
Installation
dependencies:
formstack: ^2.5.0
flutter pub get
Quick Start
import 'package:formstack/formstack.dart';
// Build and render a form
FormStack.api().form(steps: [
QuestionStep(
title: "Your Name",
inputType: InputType.name,
id: GenericIdentifier(id: "name"),
),
QuestionStep(
title: "Email",
inputType: InputType.email,
id: GenericIdentifier(id: "email"),
),
CompletionStep(
title: "Done!",
id: GenericIdentifier(id: "done"),
onFinish: (result) => print(result),
),
]);
// In your widget
Scaffold(body: FormStack.api().render());
Or load from JSON:
await FormStack.api().loadFromAsset('assets/form.json');
Scaffold(body: FormStack.api().render());
Supported Input Types
Text Inputs
| InputType | Description | Keyboard | Validation |
|---|---|---|---|
email |
Email address | Email keyboard | Regex email validation |
name |
Person name | Text, auto-capitalize words | Letters only, min 2 chars |
password |
Secure password | Visible password | Uppercase, lowercase, digit, special char, 8+ |
text |
General text | Multiline | Non-empty, configurable numberOfLines |
number |
Numeric input | Number pad | Digits only, supports mask formatting |
phone |
Phone with country code | Phone | Country code dropdown + E.164 format |
currency |
Money amount | Decimal number | Currency symbol prefix, formatted input |
Date & Time
| InputType | Description | Result Type |
|---|---|---|
date |
Date picker | DateTime |
time |
Time picker | DateTime |
dateTime |
Combined date + time | DateTime |
Choice Inputs
| InputType | Description | Selection Styles |
|---|---|---|
singleChoice |
Select one option | arrow, tick, toggle |
multipleChoice |
Select multiple | tick, toggle |
dropdown |
Dropdown menu | Standard dropdown |
ranking |
Drag-to-reorder list | Reorderable with rank numbers |
boolean |
Yes/No toggle buttons | Two-button selection |
imageChoice |
Select from a grid of images | Image cards with labels |
Survey & Rating
| InputType | Description | Result Type |
|---|---|---|
slider |
Range slider | double (configurable min/max/step) |
rating |
Star rating | int (1 to N stars) |
nps |
Net Promoter Score (0-10) | int (color-coded scale) |
smile |
Emoji satisfaction | int (1-5 scale) |
Media & Files
| InputType | Description | Result Type |
|---|---|---|
file |
File picker with filters | PlatformFile |
avatar |
Circular image upload | String (base64) |
banner |
Rectangular image upload | String (base64) |
signature |
Draw signature on canvas | String (base64 PNG) |
mapLocation |
Google Maps picker | Location coordinates |
geotrace |
Trace a path/line on map | List<Map> (lat/lng points) |
geoshape |
Draw a polygon on map | List<Map> (lat/lng points) |
Special
| InputType | Description | Result Type |
|---|---|---|
otp |
Multi-digit OTP entry | int |
consent |
Checkbox with agreement text | bool |
dynamicKeyValue |
Add/remove key-value pairs | List<KeyValue> |
htmlEditor |
Rich text editor | String |
hidden |
Hidden data field (no UI, auto-advances) | dynamic |
calculate |
Auto-computed from other results | dynamic |
barcode |
Barcode/QR scan, with manual entry fallback | String |
audio |
Audio recording with timer | String (file path) |
barcodeandaudioneed a capability from your app. FormStack declares no camera or microphone dependency, so an application that never scans or records does not inherit those SDKs. Both inputs work out of the box in degraded form —barcodefalls back to manual entry,audiorecords only a duration — and become fully functional once you register a capability. See Device capabilities.
Step Types
InstructionStep
Welcome screens, information pages, instructions.
InstructionStep(
id: GenericIdentifier(id: "welcome"),
title: "Customer Survey",
text: "This will take about 2 minutes",
cancellable: false,
display: Display.medium,
)
QuestionStep
The main input step. Supports all 28 input types.
QuestionStep(
id: GenericIdentifier(id: "email"),
title: "Email Address",
text: "We'll send your receipt here",
inputType: InputType.email,
inputStyle: InputStyle.outline,
isOptional: false,
hint: "you@example.com",
)
CompletionStep
Form completion with loading/success/error animations.
CompletionStep(
id: GenericIdentifier(id: "done"),
title: "Submitting...",
autoTrigger: false,
onFinish: (result) => print("Result: $result"),
onBeforeFinishCallback: (result) async {
await submitToApi(result);
return true; // false shows error animation
},
)
NestedStep
Multiple fields on a single screen.
NestedStep(
id: GenericIdentifier(id: "contact"),
title: "Contact Information",
verticalPadding: 10,
validationExpression: "",
steps: [
QuestionStep(title: "", inputType: InputType.name, label: "First Name",
id: GenericIdentifier(id: "first"), width: 400),
QuestionStep(title: "", inputType: InputType.name, label: "Last Name",
id: GenericIdentifier(id: "last"), width: 400),
QuestionStep(title: "", inputType: InputType.email, label: "Email",
id: GenericIdentifier(id: "email"), width: 400),
],
)
ReviewStep
Displays all collected answers for review before submission. Place before CompletionStep.
ReviewStep(
id: GenericIdentifier(id: "review"),
title: "Review Your Answers",
text: "Verify before submitting",
nextButtonText: "Submit",
)
ConsentStep
Structured consent document with expandable sections, agreement checkbox, and optional signature. Modeled after Apple ResearchKit's consent flow.
ConsentStep(
id: GenericIdentifier(id: "consent"),
title: "Informed Consent",
requiresSignature: true,
agreementText: "I agree to participate",
sections: [
ConsentSection(
type: ConsentSectionType.overview,
title: "About This Study",
summary: "Brief overview...",
content: "Full details shown on expand...",
),
ConsentSection(
type: ConsentSectionType.privacy,
title: "Your Privacy",
summary: "Data is encrypted and anonymized.",
),
ConsentSection(
type: ConsentSectionType.withdrawing,
title: "Withdrawing",
summary: "You can stop at any time.",
),
],
)
ConsentSectionType values: overview, dataGathering, privacy, dataUse, timeCommitment, studyTasks, withdrawing, custom
RepeatStep
Dynamic repeating sections where users add/remove entries. Modeled after ODK's repeat.
RepeatStep(
id: GenericIdentifier(id: "members"),
title: "Household Members",
minRepeat: 1,
maxRepeat: 10,
addButtonText: "Add Member",
steps: [
QuestionStep(title: "", inputType: InputType.name, label: "Name",
id: GenericIdentifier(id: "name"), width: 400),
QuestionStep(title: "", inputType: InputType.number, label: "Age",
id: GenericIdentifier(id: "age"), width: 400),
],
)
Result: List<Map<String, dynamic>> - one map per repetition.
DisplayStep
Show web content or data lists.
DisplayStep(
id: GenericIdentifier(id: "info"),
url: "https://example.com/terms",
displayStepType: DisplayStepType.web,
)
Validation
Built-in Validators
// Text validators
ResultFormat.email("Invalid email")
ResultFormat.name("Invalid name")
ResultFormat.password("Weak password")
ResultFormat.text("Required")
ResultFormat.number("Must be a number")
ResultFormat.phone("Invalid phone")
ResultFormat.url("Invalid URL")
// Numeric range validators
ResultFormat.min("Must be at least 18", 18)
ResultFormat.max("Cannot exceed 100", 100)
ResultFormat.range("Must be 1-10", 1, 10)
ResultFormat.age("Invalid age (0-150)")
ResultFormat.percentage("Must be 0-100")
// String length validators
ResultFormat.minLength("Too short", 3)
ResultFormat.maxLength("Too long", 50)
ResultFormat.pattern("Invalid format", r'^[A-Z]{3}\d{4}$')
ResultFormat.length("Must be exactly 6 digits", 6)
// Choice validators
ResultFormat.singleChoice("Please select one")
ResultFormat.multipleChoice("Select at least one")
ResultFormat.minSelections("Select at least 2", 2)
ResultFormat.maxSelections("Select at most 3", 3)
// Specialty validators
ResultFormat.creditCard("Invalid card number") // Luhn algorithm
ResultFormat.ssn("Invalid SSN") // ###-##-####
ResultFormat.zipCode("Invalid zip") // #####(-####)
ResultFormat.iban("Invalid IBAN") // ISO 13616
ResultFormat.consent("You must agree") // Must be true
ResultFormat.dateRange("Invalid date", "dd-MM-yyyy",
minDate: DateTime(2000), maxDate: DateTime(2030)) // Date bounds
ResultFormat.fileSize("File too large", 5242880) // Max bytes
ResultFormat.notNull("Required")
ResultFormat.notBlank("Cannot be empty")
ResultFormat.notEmpty("List cannot be empty")
// Custom validator
ResultFormat.custom("Must start with 'hello'",
(value) => value.startsWith('hello'))
// Compose multiple validators
ResultFormat.compose([
ResultFormat.minLength("Too short", 3),
ResultFormat.maxLength("Too long", 50),
ResultFormat.pattern("Letters only", r'^[a-zA-Z\s]+$'),
])
Extending FormStack
FormStack resolves input widgets, step types and validators through registries. Anything you register is available to Dart-defined and JSON-defined forms, which means you can add capabilities — or replace built-in ones — without forking the library.
Custom input types
Register a builder, then use it by name:
// Once, at start-up.
InputRegistry.instance.register(
'creditCardScanner',
(ctx) => CreditCardScannerView(ctx.form, ctx.step, ctx.text, ctx.resultFormat,
title: ctx.title),
defaultValidator: () => ResultFormat.creditCard('Invalid card number'),
);
From Dart:
QuestionStep(
id: GenericIdentifier(id: "card"),
inputType: InputType.custom,
customInputType: "creditCardScanner",
title: "Scan your card",
)
From JSON — just name it:
{ "type": "QuestionStep", "id": "card", "inputType": "creditCardScanner" }
A custom input extends BaseStepView<QuestionStep> and implements five members:
// ignore: must_be_immutable
class CreditCardScannerView extends BaseStepView<QuestionStep> {
CreditCardScannerView(super.form, super.step, super.text, this.resultFormat,
{super.title});
final ResultFormat resultFormat;
final TextEditingController _controller = TextEditingController();
@override
Widget? buildWInputWidget(BuildContext context, QuestionStep formStep) =>
TextField(controller: _controller);
@override
bool isValid() => resultFormat.isValid(_controller.text);
@override
String validationError() => resultFormat.error();
@override
dynamic resultValue() => _controller.text;
@override
void clearFocus() {}
@override
void requestFocus() {}
// Required whenever your view allocates anything disposable.
@override
void dispose() {
_controller.dispose();
super.dispose();
}
}
Replacing a built-in input
Registering an existing name overrides it for every form:
// Every `signature` step now uses your pad instead of the built-in canvas.
InputRegistry.instance.register('signature', (ctx) => CompliantSignaturePad(ctx));
This is also how you make barcode and audio real — see the note under
Media & Files.
Device capabilities
Two inputs need hardware FormStack does not depend on. Supply the capability once at start-up and the built-in widgets use it — you keep FormStack's layout, validation and result handling and replace only the capture step.
void main() {
DeviceCapabilities.instance
..barcodeScanner = MobileScannerAdapter()
..audioRecorder = RecordAdapter();
runApp(const MyApp());
}
class MobileScannerAdapter implements BarcodeScanner {
@override
Future<String?> scan(BuildContext context) => Navigator.of(context).push(
MaterialPageRoute(builder: (_) => const MyScannerPage()),
);
}
class RecordAdapter implements AudioRecorder {
final _recorder = AudioRecorder(); // from package:record
DateTime? _startedAt;
@override
Future<void> start() async {
_startedAt = DateTime.now();
await _recorder.start(const RecordConfig(), path: await _tempPath());
}
@override
Future<AudioRecording?> stop() async {
final path = await _recorder.stop();
if (path == null) return null;
return AudioRecording(
path: path,
duration: DateTime.now().difference(_startedAt!),
);
}
}
Returning null from scan or stop means the user cancelled and leaves any
existing answer untouched. Throwing is reported through FlutterError rather
than crashing the form.
With a scanner registered, InputType.barcode stores the scanned string; with
a recorder, InputType.audio stores the recording's file path. Without them
the inputs still render and the form still completes.
To replace an input entirely rather than just its capture step, register a
widget with InputRegistry.
A runnable version of everything in this section — a custom input, a named validator, a device capability and a scoped theme — is in the example app under Extension Points (
example/lib/extensibility_demo.dart).
Custom step types
StepRegistry.instance.register(
'PaymentStep',
(json, conditions) => PaymentStep.from(json, conditions),
);
{"type": "PaymentStep", ...} is then parseable by every JSON loader.
Custom validators
ResultFormat.register(
'nhsNumber',
(message, args) => NhsNumberFormat(message),
);
{"type": "nhsNumber", "message": "Not a valid NHS number"}
Validation in JSON
JSON-defined steps can use the full validator library, not just the default
implied by inputType. Declare validators as a single object or a list — a
list is evaluated in order and reports the first failure:
{
"type": "QuestionStep",
"id": "age",
"title": "Your age",
"inputType": "number",
"validators": [
{"type": "notBlank", "message": "Age is required"},
{"type": "range", "message": "Must be between 18 and 120", "min": 18, "max": 120}
]
}
Every ResultFormat factory has a JSON name. Arguments map to the factory's
parameters: min, max, count, maxBytes, regex, expression, format,
minDate, maxDate.
Structured validation results
validate() returns a stable code and the constraint parameters alongside the
message, so failures can be localized or reported without matching on strings:
final outcome = ResultFormat.range("Must be 18-120", 18, 120).validate(5);
outcome.isValid; // false
outcome.code; // 'range'
outcome.params; // {'min': 18, 'max': 120}
outcome.message; // 'Must be 18-120'
Localize by mapping the code through your own catalogue:
String localize(ValidationResult r, FormStackLocale l10n) => r.isValid
? ''
: l10n.tf('validation.${r.code}', [...r.params.values.map((v) => '$v')]);
isValid() and error() continue to work unchanged.
Performance notes
- Only the step on screen is retained. Step views are owned by the widget
tree: leaving a step disposes its view and releases its controllers, so a
hundred-step survey holds one view, not a hundred. If you build custom
inputs, override
dispose()and callsuper.dispose(). - Restore from the step, not from your own fields. A view is rebuilt when
the user navigates back, so read the previous answer from
formStep.resultinbuildWInputWidget. The answer is written back before every navigation. - Step lookup is indexed.
getStep,getCurrentIndexandprogressare constant-time rather than walking the step list. - Use
form.progressrather than callinggetCurrentIndex()andgetTotalSteps()separately — it computes both in one pass.
Styling
Input Styles
inputStyle: InputStyle.basic // Flat, no border
inputStyle: InputStyle.outline // Full border
inputStyle: InputStyle.underLined // Bottom border only
Component Styles
componentsStyle: ComponentsStyle.minimal // Clean, minimal
componentsStyle: ComponentsStyle.basic // Card-style with background
Display Sizes
display: Display.small // Compact
display: Display.normal // Standard (default)
display: Display.medium // Larger headings
display: Display.large // Big text
display: Display.extraLarge // Maximum size
Selection Types (for choices)
selectionType: SelectionType.arrow // Navigate arrows
selectionType: SelectionType.tick // Checkmark
selectionType: SelectionType.toggle // Switch toggle
selectionType: SelectionType.dropdown // Dropdown menu
Custom Theme (Dart)
QuestionStep(
title: "Styled",
inputType: InputType.text,
style: UIStyle(
Colors.indigo, // Button background
Colors.white, // Button foreground
Colors.indigo, // Input border color
8.0, // Title bottom padding
12.0, // Button border radius
inputBackground: Colors.grey.shade100,
inputTextColor: Colors.black87,
titleColor: Colors.indigo,
iconColor: Colors.indigo,
cardBackground: Colors.white,
fontSize: 16.0,
),
)
Custom Theme (JSON)
Apply a form-level theme to all steps, or style individual steps:
{
"my_form": {
"theme": {
"backgroundColor": "#3F51B5",
"foregroundColor": "#FFFFFF",
"borderColor": "#3F51B5",
"borderRadius": 12,
"inputBackground": "#F5F5F5",
"inputTextColor": "#212121",
"titleColor": "#3F51B5",
"subtitleColor": "#757575",
"iconColor": "#3F51B5",
"cardBackground": "#FFFFFF",
"fontSize": 16
},
"steps": [
{
"type": "QuestionStep",
"id": "name",
"title": "Your Name",
"inputType": "name",
"style": {
"backgroundColor": "#FF5722",
"foregroundColor": "#FFFFFF",
"borderRadius": 24
}
}
]
}
}
The theme key applies to all steps as a default. Individual step style overrides the form theme.
Dark Mode
FormStack automatically adapts to your app's theme. No configuration needed:
MaterialApp(
theme: ThemeData.light(useMaterial3: true),
darkTheme: ThemeData.dark(useMaterial3: true),
themeMode: ThemeMode.system, // Auto dark/light
home: Scaffold(body: FormStack.api().render()),
)
All colors resolve from Theme.of(context).colorScheme at runtime.
Cascading Selects
Filter choices based on previous answers:
QuestionStep(
title: "State",
inputType: InputType.dropdown,
options: allStates, // Full list
choiceFilter: (options, results) =>
options.where((o) => o.value == results["country"]).toList(),
)
Calculated Fields
Auto-compute values from other step results:
QuestionStep(
title: "BMI",
inputType: InputType.calculate,
calculateCallback: (results) {
final weight = double.tryParse(results["weight"]?.toString() ?? "") ?? 0;
final height = double.tryParse(results["height"]?.toString() ?? "") ?? 1;
return (weight / (height * height)).toStringAsFixed(1);
},
helperText: "Calculated from height and weight",
)
Multi-Language Support
final locale = FormStackLocale(
defaultLocale: 'en',
translations: {
'en': {'name_title': 'Your Name', 'name_hint': 'Enter full name'},
'es': {'name_title': 'Tu Nombre', 'name_hint': 'Ingrese nombre completo'},
'fr': {'name_title': 'Votre Nom', 'name_hint': 'Entrez le nom complet'},
},
);
QuestionStep(
title: locale.t('name_title'),
hint: locale.t('name_hint'),
inputType: InputType.name,
)
// Switch language at runtime
locale.setLocale('es');
Conditional Navigation
Route users to different steps based on their answers:
QuestionStep(
id: GenericIdentifier(id: "role"),
title: "Your Role",
inputType: InputType.singleChoice,
autoTrigger: true,
options: [
Options("dev", "Developer"),
Options("designer", "Designer"),
],
relevantConditions: [
ExpressionRelevant(
identifier: GenericIdentifier(id: "dev_questions"),
expression: "IN dev",
),
ExpressionRelevant(
identifier: GenericIdentifier(id: "design_questions"),
expression: "IN designer",
),
],
)
Expression syntax:
IN value- Result contains the valueNOT_IN value- Result does not contain the valueFOR_ALL- Always matches (converge paths)= value/!= value- Exact match
Cross-form navigation with formName:
ExpressionRelevant(
identifier: GenericIdentifier(id: "step_id"),
expression: "IN selected_option",
formName: "another_form", // Navigate to a different form
)
API Reference
FormStack
// Create/get instance (supports named instances)
FormStack.api()
FormStack.api(name: "myForm")
// Build form from Dart objects
FormStack.api().form(steps: [...], name: "formName")
// Load from JSON
await FormStack.api().loadFromAsset('assets/form.json')
await FormStack.api().loadFromAssets(['assets/a.json', 'assets/b.json'])
// Render
FormStack.api().render()
FormStack.api().render(name: "formName")
// Pre-fill data
FormStack.api().setResult({"email": "user@test.com"}, formName: "myForm")
// Set validation error on a field
FormStack.api().setError(GenericIdentifier(id: "email"), "Already taken", formName: "myForm")
// Update options dynamically
FormStack.api().setOptions([Options("a", "A")], GenericIdentifier(id: "choice"))
// Disable specific fields
FormStack.api().setDisabledUI(["field_id_1", "field_id_2"])
// Completion callback with async pre-validation
FormStack.api().addCompletionCallback(
GenericIdentifier(id: "done"),
formName: "myForm",
onFinish: (result) => print(result),
onBeforeFinishCallback: (result) async {
final success = await api.submit(result);
return success; // false = show error animation
},
);
// Progress tracking
double progress = FormStack.api().getFormProgress(); // 0.0 - 1.0
int step = FormStack.api().getCurrentStepIndex();
bool done = FormStack.api().isFormCompleted();
Map stats = FormStack.api().getFormStats();
// stats: {totalSteps, completedSteps, requiredSteps, optionalSteps, progress, isCompleted}
// Back navigation control
FormStack.api().systemBackNavigation(true, () => print("Back pressed"));
// Cleanup
FormStack.clearForms();
FormStack.api().clearConfiguration();
JSON Schema
All step types and properties are supported in JSON. Wrap forms in a named object:
{
"my_form": {
"backgroundAnimationFile": "assets/bg.json",
"steps": [
{
"type": "InstructionStep",
"id": "welcome",
"title": "Welcome",
"text": "Complete this survey",
"cancellable": false,
"display": "medium"
},
{
"type": "QuestionStep",
"id": "name",
"title": "Full Name",
"inputType": "name",
"inputStyle": "outline",
"isOptional": false,
"hint": "John Doe",
"helperText": "Enter your legal name",
"validators": [
{"type": "notBlank", "message": "Name is required"},
{"type": "minLength", "message": "Too short", "min": 2}
]
},
{
"type": "QuestionStep",
"id": "satisfaction",
"title": "How satisfied are you?",
"inputType": "slider",
"minValue": 0,
"maxValue": 10,
"stepValue": 1,
"defaultValue": 5
},
{
"type": "QuestionStep",
"id": "rating",
"title": "Rate our service",
"inputType": "rating",
"ratingCount": 5
},
{
"type": "QuestionStep",
"id": "recommend",
"title": "Would you recommend us?",
"inputType": "nps"
},
{
"type": "QuestionStep",
"id": "country",
"title": "Country",
"inputType": "dropdown",
"componentsStyle": "basic",
"options": [
{"key": "US", "title": "United States"},
{"key": "UK", "title": "United Kingdom"},
{"key": "IN", "title": "India"}
]
},
{
"type": "QuestionStep",
"id": "phone",
"title": "Phone",
"inputType": "phone",
"phoneCountryCode": "+1"
},
{
"type": "QuestionStep",
"id": "budget",
"title": "Budget",
"inputType": "currency",
"currencySymbol": "$"
},
{
"type": "QuestionStep",
"id": "priorities",
"title": "Rank by priority",
"inputType": "ranking",
"options": [
{"key": "speed", "title": "Speed"},
{"key": "quality", "title": "Quality"},
{"key": "cost", "title": "Cost"}
]
},
{
"type": "QuestionStep",
"id": "agree",
"title": "Agreement",
"inputType": "consent",
"consentText": "I agree to the terms and conditions"
},
{
"type": "QuestionStep",
"id": "sig",
"title": "Signature",
"inputType": "signature"
},
{
"type": "CompletionStep",
"id": "done",
"title": "Thank you!",
"autoTrigger": true
}
]
}
}
Supported JSON Fields
All Steps: type, id, title, text, description, hint, label, display, isOptional, cancellable, disabled, nextButtonText, backButtonText, cancelButtonText, footerBackButton, componentsStyle, crossAxisAlignmentContent, titleIconAnimationFile, titleIconMaxWidth, width, helperText, defaultValue, semanticLabel, style
QuestionStep: inputType, inputStyle, options, selectionType, autoTrigger, numberOfLines, count, maxCount, mask, filter, maxHeight, lengthLimit, textAlign, relevantConditions, minValue, maxValue, stepValue, minSelections, maxSelections, consentText, currencySymbol, phoneCountryCode, ratingCount
CompletionStep: autoTrigger, successLottieAssetsFilePath, loadingLottieAssetsFilePath, errorLottieAssetsFilePath
NestedStep: steps, validationExpression, verticalPadding
DisplayStep: url, displayStepType, data
Examples
The example app demonstrates all features across 12 demo screens:
| Demo | Features |
|---|---|
| All Input Types | Core input types: text, email, name, password, number, date, time, choices, OTP, smile, file, key-value, avatar, banner |
| Styles & Display | InputStyle, ComponentsStyle, Display sizes, UIStyle, JSON theming, dark mode |
| Selection Types | Arrow, tick, toggle, dropdown |
| Validation | Email, password, phone, URL, age, zip, dateRange, custom, compose |
| Conditional Nav | ExpressionRelevant branching and path convergence |
| Nested Steps | Multi-field screens with cross-field validation |
| API Features | setResult, setError, callbacks, progress tracking |
| Survey Components | Slider, rating, NPS, consent, signature, ranking, phone, currency |
| ResearchKit Features | Boolean, image choice, consent flow, review step, progress bar, timestamps |
| Data Collection (ODK) | RepeatStep, calculate, hidden, cascading selects, barcode, audio, geotrace, geoshape |
| Multi-Language & Offline | FormStackLocale, runtime language switching, DisplayStep, offline save/resume |
| Load from JSON | Multi-file JSON loading with form linking |
Run the example:
cd example
flutter run
Extending by Subclassing
The registries are the way to make an extension available to JSON forms and to override built-ins. When you only need a one-off used from Dart, you can also pass a subclass directly.
Custom Validators
Subclass ResultFormat directly:
class PalindromeValidator extends ResultFormat {
final String errorMsg;
PalindromeValidator(this.errorMsg);
@override
bool isValid(dynamic input) {
final str = (input as String?)?.toLowerCase() ?? '';
return str == str.split('').reversed.join();
}
@override
String error() => errorMsg;
}
// Use it
QuestionStep(
inputType: InputType.text,
resultFormat: PalindromeValidator("Must be a palindrome"),
)
Custom Input Widgets
Extend BaseStepView to create custom inputs:
// ignore: must_be_immutable
class ColorPickerWidget extends BaseStepView<QuestionStep> {
ColorPickerWidget(super.formStackForm, super.formStep, super.text);
Color _selected = Colors.red;
final ValueNotifier<Color> _notifier = ValueNotifier(Colors.red);
@override
Widget? buildWInputWidget(BuildContext context, QuestionStep formStep) {
return StatefulBuilder(builder: (context, setState) {
// Your custom color picker UI here
});
}
@override
bool isValid() => true;
@override
String validationError() => "";
@override
dynamic resultValue() => _selected.value;
@override
void requestFocus() {}
@override
void clearFocus() {}
// Step views are StatelessWidgets, so the framework never disposes them --
// the form does. Release anything disposable here and call super.
@override
void dispose() {
_notifier.dispose();
super.dispose();
}
}
Custom Step Types
Subclass FormStep for entirely new step types:
class VideoStep extends FormStep {
final String videoUrl;
VideoStep({required this.videoUrl, super.id, super.title});
@override
FormStepView buildView(FormStackForm formStackForm) {
return MyVideoStepView(formStackForm, this, text);
}
}
Custom Navigation Rules
Subclass RelevantCondition:
class ScoreThresholdCondition extends RelevantCondition {
final int threshold;
ScoreThresholdCondition({required super.identifier, required this.threshold});
@override
bool isValid(dynamic result) => (result as int?) != null && result >= threshold;
}
Step Lifecycle Callbacks
Hook into step events for analytics or custom behavior:
QuestionStep(
title: "Email",
inputType: InputType.email,
onStepWillPresent: (step) => analytics.trackStepView(step.id?.id),
onStepDidComplete: (step, result) => analytics.trackStepComplete(step.id?.id, result),
)
Structured Result Export
Get typed results with timestamps (modeled after ResearchKit's ORKTaskResult):
final taskResult = FormStack.api().getTaskResult(formName: "myForm");
print(taskResult.totalDuration);
print(taskResult.completedSteps);
// Full JSON export with step-level timestamps and metadata
final json = FormStack.api().exportAsJson(formName: "myForm");
await http.post('/api/submit', body: jsonEncode(json));
// Access individual step results
final email = FormStack.api().getStepResult("email");
final step = FormStack.api().getStep("email");
print(step?.startTime); // When user saw this step
print(step?.endTime); // When user completed it
Architecture
lib/
formstack.dart # Public API exports
src/
formstack.dart # FormStack singleton API
formstack_form.dart # Form navigation and state
input_types.dart # InputType enum (35 values)
core/
form_step.dart # Base FormStep class, enums
parser.dart # JSON parser (registry-driven)
ui_style.dart # UIStyle, HexColor
form_locale.dart # FormStackLocale (multi-language)
form_persistence.dart # FormPersistence port, in-memory impl
external_data.dart # ExternalDataProvider port
registry/
input_registry.dart # Pluggable input widgets
step_registry.dart # Pluggable step types
validator_registry.dart # Named validators, JSON validator support
validation/
validation_result.dart # Structured validation outcome
step/
question_step.dart # QuestionStep (all input types)
completion_step.dart # CompletionStep (finish with animation)
instruction_step.dart # InstructionStep (info/video screens)
nested_step.dart # NestedStep (multi-field)
display_step.dart # DisplayStep (web/list content)
review_step.dart # ReviewStep (answer review before submit)
consent_step.dart # ConsentStep (consent document flow)
repeat_step.dart # RepeatStep (dynamic repeating sections)
pop_step.dart # PopStep (navigation)
result/
result_format.dart # 35+ validators (subclassable)
step_result.dart # StepResult, TaskResult hierarchy
common_result.dart # Options, KeyValue, DynamicData
identifiers.dart # GenericIdentifier, StepIdentifier
relevant/
relevant_condition.dart # RelevantCondition base
expression_relevant_condition.dart # Expression-based routing
dynamic_relevant_condition.dart # Callback-based routing
ui/views/input/
text_input_field.dart # Text/email/password/number
choice_input_field.dart # Single/multiple choice
date_input_field.dart # Date/time pickers
slider_input_field.dart # Range slider
rating_input_field.dart # Star rating
nps_input_field.dart # Net Promoter Score
consent_input_field.dart # Checkbox consent
signature_input_field.dart # Signature pad
ranking_input_field.dart # Drag-to-reorder
phone_input_field.dart # Phone with country code
currency_input_field.dart # Currency input
boolean_input_field.dart # Yes/No toggle
image_choice_input_field.dart # Image grid selection
otp_input_field.dart # OTP digits
smile_input_field.dart # Emoji rating
image_input_field.dart # Avatar/banner upload
dynamic_key_value_field.dart # Key-value pairs
map_input_field.dart # Google Maps
hidden_input_field.dart # Hidden data field
calculate_input_field.dart # Calculated field
barcode_input_field.dart # Barcode/QR scanner
audio_input_field.dart # Audio recording
geotrace_input_field.dart # Geotrace/geoshape map input
html_input_field.dart # Rich text editor
For the layering rationale, the view-ownership rule, and the list of known architectural debt, see ARCHITECTURE.md.
License
MIT License. See LICENSE for details.
Links
Libraries
- formstack
- FormStack - A dynamic form and survey builder for Flutter.