
Lightweight FHIR-inspired questionnaire engine with reactive state, JsonLogic-driven validation/visibility/calculations, type-safe models, extensible evaluator, rich UI widgets, pagination, repeating groups and themed summary view.
A lightweight, FHIR-inspired questionnaire library for Kotlin Multiplatform applications.
Add to your build.gradle.kts:
kotlin {
sourceSets {
commonMain {
dependencies {
implementation("io.github.ellykits.litequest:litequest-library:1.0.0-alpha05")
}
}
}
}@Composable
fun MyQuestionnaireScreen() {
val questionnaire = Questionnaire(
id = "patient-intake",
version = "1.0.0",
title = "Patient Intake Form",
items = listOf(
Item(
linkId = "name",
type = ItemType.TEXT,
text = "What is your full name?",
required = true
),
Item(
linkId = "age",
type = ItemType.INTEGER,
text = "What is your age?",
required = true
)
)
)
val evaluator = remember { LiteQuestEvaluator(questionnaire) }
val manager = remember { QuestionnaireManager(questionnaire, evaluator) }
val state by manager.state.collectAsState()
var mode by remember { mutableStateOf(QuestionnaireMode.Edit) }
QuestionnaireScreen(
type = QuestionnaireType.Single(questionnaire),
manager = manager,
mode = mode,
onModeChange = { newMode -> mode = newMode },
onSubmit = { println("Form submitted: ${state.response}") },
onDismiss = { /* Handle dismiss */ }
)
}val paginatedQuestionnaire = PaginatedQuestionnaire(
id = "health-survey",
title = "Health Survey",
pages = listOf(
QuestionnairePage(
id = "demographics",
title = "Demographics",
order = 0,
items = listOf(/* page 1 items */)
),
QuestionnairePage(
id = "health-history",
title = "Health History",
order = 1,
items = listOf(/* page 2 items */)
)
)
)
val flatQuestionnaire = Questionnaire(
id = paginatedQuestionnaire.id,
title = paginatedQuestionnaire.title,
version = paginatedQuestionnaire.version,
items = paginatedQuestionnaire.pages.flatMap { it.items }
)
val evaluator = LiteQuestEvaluator(flatQuestionnaire)
val manager = QuestionnaireManager(flatQuestionnaire, evaluator)
QuestionnaireScreen(
type = QuestionnaireType.Paginated(paginatedQuestionnaire),
manager = manager,
onModeChange = null,
onSubmit = { /* Handle submission */ },
onDismiss = { /* Handle dismiss */ }
)Visibility conditions (skip logic) support both simple and nested paths:
// Simple row-scoped visibility
Item(
linkId = "symptoms",
text = "Please describe your symptoms",
visibleIf = buildJsonObject {
put("==", buildJsonArray {
add(buildJsonObject { put("var", "has-symptoms") })
add(JsonPrimitive(true))
})
}
)
// Qualified paths inside repeating groups
// If inside 'receivedItems', it correctly resolves to the current row
Item(
linkId = "itemId",
text = "Item ID",
visibleIf = buildJsonObject {
put("==", buildJsonArray {
add(buildJsonObject { put("var", "receivedItems.method") })
add(JsonPrimitive("SEARCH"))
})
}
)
// Logic with negation
Item(
linkId = "additionalNote",
text = "Additional Note",
visibleIf = buildJsonObject {
put("!", buildJsonObject { put("var", "skipNotes") })
}
)Calculated expressions:
// BMI calculation
Item(
linkId = "bmi",
type = ItemType.DECIMAL,
text = "Body Mass Index",
readOnly = true,
calculatedExpression = buildJsonObject {
put("/", buildJsonArray {
add(buildJsonObject { put("var", "weight") })
add(buildJsonObject {
put("*", buildJsonArray {
add(buildJsonObject { put("var", "height") })
add(buildJsonObject { put("var", "height") })
})
})
})
}
)
// String concatenation
Item(
linkId = "fullName",
type = ItemType.STRING,
text = "Full Name",
readOnly = true,
calculatedExpression = buildJsonObject {
put("cat", buildJsonArray {
add(buildJsonObject { put("var", "firstName") })
add(JsonPrimitive(" "))
add(buildJsonObject { put("var", "lastName") })
})
}
)class RatingWidget(override val item: Item) : ItemWidget {
@Composable
override fun Render(
value: JsonElement?,
onValueChange: (JsonElement, String?) -> Unit,
errorMessage: String?
) {
val rating = value?.jsonPrimitive?.intOrNull ?: 0
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
repeat(5) { index ->
Icon(
imageVector = if (index < rating) Icons.Filled.Star else Icons.Outlined.Star,
contentDescription = "Star ${index + 1}",
modifier = Modifier
.size(32.dp)
.clickable { onValueChange(JsonPrimitive(index + 1), null) },
tint = if (index < rating) Color(0xFFFFB300) else Color.Gray
)
}
}
}
}
// 2. Register custom widget in the factory
val factory = DefaultWidgetFactory().apply {
registerWidget(ItemType("RATING")) { RatingWidget(it) }
}
// 3. Pass factory to QuestionnaireManager
val manager = QuestionnaireManager(
questionnaire = questionnaire,
evaluator = evaluator,
widgetFactory = factory
)
// 4. Use in QuestionnaireScreen
QuestionnaireScreen(
type = QuestionnaireType.Single(questionnaire),
manager = manager,
onSubmit = { /* handle submit */ }
)LiteQuestEvaluator accepts a JsonLogicEvaluator instance:
val evaluator = LiteQuestEvaluator(questionnaire, JsonLogicEvaluator())
val manager = QuestionnaireManager(questionnaire, evaluator)Custom operators are not yet pluggable through overrides. If you need extra operators, extend JsonLogicEvaluator.kt in the library source.
LiteQuest follows a clean, layered architecture:
library/
āāā model/ # Data structures (Questionnaire, Item, Response)
āāā engine/ # JsonLogic evaluation, validation, visibility, calculations
āāā state/ # QuestionnaireManager - reactive state orchestration
āāā ui/ # Compose UI components
ā āāā screen/ # QuestionnaireScreen - unified Edit/Summary screen
ā āāā widget/ # Input widgets for different item types
ā āāā summary/ # Summary/review page components
ā āāā pagination/ # Multi-page support with navigation
ā āāā renderer/ # Form rendering logic
āāā util/ # Helper utilitiesLiteQuest uses a custom Kotlin Multiplatform implementation of JsonLogic for all dynamic behavior. This pure-Kotlin evaluator works across all platforms (Android, iOS, Desktop, Web) without external dependencies.
Supported Operators:
| Operator | Category | Description | Example |
|---|---|---|---|
var |
Variables | Access form field values with support for Row-Scoped Evaluation (e.g. receivedItems.method resolves to current row) and dot notation for global paths. |
{"var": "firstName"} or {"var": "receivedItems.method"}
|
== |
Comparison | Equality check - returns true if values are equal | {"==": [{"var": "age"}, 18]} |
!= |
Comparison | Inequality check - returns true if values are not equal | {"!=": [{"var": "status"}, "active"]} |
> |
Comparison | Greater than - numeric comparison | {">": [{"var": "age"}, 18]} |
>= |
Comparison | Greater than or equal to - numeric comparison | {">=": [{"var": "score"}, 70]} |
< |
Comparison | Less than - numeric comparison | {"<": [{"var": "temperature"}, 38]} |
<= |
Comparison | Less than or equal to - numeric comparison | {"<=": [{"var": "bmi"}, 25]} |
and |
Logic | Logical AND - returns true if all conditions are true | {"and": [{"var": "isAdult"}, {"var": "hasConsent"}]} |
or |
Logic | Logical OR - returns true if any condition is true | {"or": [{"var": "isEmergency"}, {"var": "hasPermission"}]} |
! |
Logic | Logical NOT - negates a value. | {"!": {"var": "isDisabled"}} |
!! |
Logic | Truthy check - returns true if value exists and is truthy | {"!!": {"var": "optionalField"}} |
if |
Conditional | Ternary conditional - if/then/else logic | {"if": [{"var": "isAdult"}, "adult", "minor"]} |
+ |
Arithmetic | Addition - sums numeric values | {"+": [{"var": "score1"}, {"var": "score2"}]} |
- |
Arithmetic | Subtraction - subtracts second value from first | {"-": [{"var": "total"}, {"var": "discount"}]} |
* |
Arithmetic | Multiplication - multiplies numeric values | {"*": [{"var": "price"}, {"var": "quantity"}]} |
/ |
Arithmetic | Division - divides first value by second | {"/": [{"var": "weight"}, {"var": "height"}]} |
% |
Arithmetic | Modulo - returns remainder of division | {"%": [{"var": "number"}, 2]} |
cat |
String | Concatenation - joins strings together | {"cat": [{"var": "firstName"}, " ", {"var": "lastName"}]} |
Implementation:
JsonLogicEvaluator.kt - Core evaluator engineVisibilityEngine.kt - Skip logic using JsonLogicCalculatedValuesEngine.kt - Computed fields using JsonLogicValidationEngine.kt - Custom validation rules using JsonLogicState updates propagate automatically:
Answer Change ā Recalculate Values ā Update Visibility ā Revalidate ā Emit New State| ItemType | Widget | Data Type | Features |
|---|---|---|---|
| STRING | TextInputWidget | String | Single-line text input |
| TEXT | TextInputWidget | String | Multi-line text area |
| BOOLEAN | BooleanWidget | Boolean | Switch/Checkbox toggle |
| DECIMAL | DecimalInputWidget | Double | Numeric keyboard with decimal support |
| INTEGER | IntegerInputWidget | Int | Numeric keyboard for whole numbers |
| DATE | DatePickerWidget | String (ISO) | Platform-native date selection |
| TIME | TimePickerWidget | String (ISO) | Platform-native time selection |
| DATETIME | DateTimePickerWidget | String (ISO) | Combined date and time selection |
| CHOICE | ChoiceWidget | String(s) | Radio buttons, Dropdowns, or Chips |
| OPEN_CHOICE | OpenChoiceWidget | String(s) | Choice with "Other" free-text option |
| DISPLAY | DisplayWidget | N/A | Static text or instructional content |
| GROUP | GroupWidget | N/A | Logical grouping of items, supports repetition |
| QUANTITY | QuantityWidget | Object | Numeric value with associated unit |
| BARCODE | BarcodeScannerWidget | String | Integrated camera barcode scanning (KScan) |
| IMAGE | ImageSelectorWidget | File/Base64 | Image capture or gallery selection (FileKit) |
| ATTACHMENT | AttachmentWidget | File/Base64 | Generic file attachment support (FileKit) |
| LAYOUT_ROW | RowLayoutWidget | N/A | Horizontal arrangement of child widgets |
| LAYOUT_COLUMN | ColumnLayoutWidget | N/A | Vertical arrangement of child widgets |
| LAYOUT_BOX | BoxLayoutWidget | N/A | Stacked or layered arrangement of child widgets |
./gradlew :demo:run./gradlew :demo:installDebugOpen iosDemo/iosDemo.xcodeproj in Xcode and run.
# Run all tests
./gradlew :library:desktopTest
# Run platform-specific tests
./gradlew :library:androidUnitTest
./gradlew :library:iosSimulatorArm64Test# Build library
./gradlew :library:assemble
# Build demo app
./gradlew :demo:assembleDebug| Platform | Status | Min Version |
|---|---|---|
| Android | ā Stable | API 24 (Android 7.0) |
| iOS | ā Stable | iOS 14.0+ |
| Desktop | ā Stable | JVM 11+ |
| Web (WASM) | N/A |
We welcome contributions! Please see CONTRIBUTING.md for guidelines.
./gradlew :library:desktopTest
./gradlew :demo:run
Copyright 2025 LiteQuest Contributors
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
https://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.Based on the LiteQuest Technical Specification v1.0.0, inspired by HL7 FHIR Questionnaire resources.
Special thanks to all contributors.
Made with ā¤ļø by the LiteQuest community
A lightweight, FHIR-inspired questionnaire library for Kotlin Multiplatform applications.
Add to your build.gradle.kts:
kotlin {
sourceSets {
commonMain {
dependencies {
implementation("io.github.ellykits.litequest:litequest-library:1.0.0-alpha05")
}
}
}
}@Composable
fun MyQuestionnaireScreen() {
val questionnaire = Questionnaire(
id = "patient-intake",
version = "1.0.0",
title = "Patient Intake Form",
items = listOf(
Item(
linkId = "name",
type = ItemType.TEXT,
text = "What is your full name?",
required = true
),
Item(
linkId = "age",
type = ItemType.INTEGER,
text = "What is your age?",
required = true
)
)
)
val evaluator = remember { LiteQuestEvaluator(questionnaire) }
val manager = remember { QuestionnaireManager(questionnaire, evaluator) }
val state by manager.state.collectAsState()
var mode by remember { mutableStateOf(QuestionnaireMode.Edit) }
QuestionnaireScreen(
type = QuestionnaireType.Single(questionnaire),
manager = manager,
mode = mode,
onModeChange = { newMode -> mode = newMode },
onSubmit = { println("Form submitted: ${state.response}") },
onDismiss = { /* Handle dismiss */ }
)
}val paginatedQuestionnaire = PaginatedQuestionnaire(
id = "health-survey",
title = "Health Survey",
pages = listOf(
QuestionnairePage(
id = "demographics",
title = "Demographics",
order = 0,
items = listOf(/* page 1 items */)
),
QuestionnairePage(
id = "health-history",
title = "Health History",
order = 1,
items = listOf(/* page 2 items */)
)
)
)
val flatQuestionnaire = Questionnaire(
id = paginatedQuestionnaire.id,
title = paginatedQuestionnaire.title,
version = paginatedQuestionnaire.version,
items = paginatedQuestionnaire.pages.flatMap { it.items }
)
val evaluator = LiteQuestEvaluator(flatQuestionnaire)
val manager = QuestionnaireManager(flatQuestionnaire, evaluator)
QuestionnaireScreen(
type = QuestionnaireType.Paginated(paginatedQuestionnaire),
manager = manager,
onModeChange = null,
onSubmit = { /* Handle submission */ },
onDismiss = { /* Handle dismiss */ }
)Visibility conditions (skip logic) support both simple and nested paths:
// Simple row-scoped visibility
Item(
linkId = "symptoms",
text = "Please describe your symptoms",
visibleIf = buildJsonObject {
put("==", buildJsonArray {
add(buildJsonObject { put("var", "has-symptoms") })
add(JsonPrimitive(true))
})
}
)
// Qualified paths inside repeating groups
// If inside 'receivedItems', it correctly resolves to the current row
Item(
linkId = "itemId",
text = "Item ID",
visibleIf = buildJsonObject {
put("==", buildJsonArray {
add(buildJsonObject { put("var", "receivedItems.method") })
add(JsonPrimitive("SEARCH"))
})
}
)
// Logic with negation
Item(
linkId = "additionalNote",
text = "Additional Note",
visibleIf = buildJsonObject {
put("!", buildJsonObject { put("var", "skipNotes") })
}
)Calculated expressions:
// BMI calculation
Item(
linkId = "bmi",
type = ItemType.DECIMAL,
text = "Body Mass Index",
readOnly = true,
calculatedExpression = buildJsonObject {
put("/", buildJsonArray {
add(buildJsonObject { put("var", "weight") })
add(buildJsonObject {
put("*", buildJsonArray {
add(buildJsonObject { put("var", "height") })
add(buildJsonObject { put("var", "height") })
})
})
})
}
)
// String concatenation
Item(
linkId = "fullName",
type = ItemType.STRING,
text = "Full Name",
readOnly = true,
calculatedExpression = buildJsonObject {
put("cat", buildJsonArray {
add(buildJsonObject { put("var", "firstName") })
add(JsonPrimitive(" "))
add(buildJsonObject { put("var", "lastName") })
})
}
)class RatingWidget(override val item: Item) : ItemWidget {
@Composable
override fun Render(
value: JsonElement?,
onValueChange: (JsonElement, String?) -> Unit,
errorMessage: String?
) {
val rating = value?.jsonPrimitive?.intOrNull ?: 0
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
repeat(5) { index ->
Icon(
imageVector = if (index < rating) Icons.Filled.Star else Icons.Outlined.Star,
contentDescription = "Star ${index + 1}",
modifier = Modifier
.size(32.dp)
.clickable { onValueChange(JsonPrimitive(index + 1), null) },
tint = if (index < rating) Color(0xFFFFB300) else Color.Gray
)
}
}
}
}
// 2. Register custom widget in the factory
val factory = DefaultWidgetFactory().apply {
registerWidget(ItemType("RATING")) { RatingWidget(it) }
}
// 3. Pass factory to QuestionnaireManager
val manager = QuestionnaireManager(
questionnaire = questionnaire,
evaluator = evaluator,
widgetFactory = factory
)
// 4. Use in QuestionnaireScreen
QuestionnaireScreen(
type = QuestionnaireType.Single(questionnaire),
manager = manager,
onSubmit = { /* handle submit */ }
)LiteQuestEvaluator accepts a JsonLogicEvaluator instance:
val evaluator = LiteQuestEvaluator(questionnaire, JsonLogicEvaluator())
val manager = QuestionnaireManager(questionnaire, evaluator)Custom operators are not yet pluggable through overrides. If you need extra operators, extend JsonLogicEvaluator.kt in the library source.
LiteQuest follows a clean, layered architecture:
library/
āāā model/ # Data structures (Questionnaire, Item, Response)
āāā engine/ # JsonLogic evaluation, validation, visibility, calculations
āāā state/ # QuestionnaireManager - reactive state orchestration
āāā ui/ # Compose UI components
ā āāā screen/ # QuestionnaireScreen - unified Edit/Summary screen
ā āāā widget/ # Input widgets for different item types
ā āāā summary/ # Summary/review page components
ā āāā pagination/ # Multi-page support with navigation
ā āāā renderer/ # Form rendering logic
āāā util/ # Helper utilitiesLiteQuest uses a custom Kotlin Multiplatform implementation of JsonLogic for all dynamic behavior. This pure-Kotlin evaluator works across all platforms (Android, iOS, Desktop, Web) without external dependencies.
Supported Operators:
| Operator | Category | Description | Example |
|---|---|---|---|
var |
Variables | Access form field values with support for Row-Scoped Evaluation (e.g. receivedItems.method resolves to current row) and dot notation for global paths. |
{"var": "firstName"} or {"var": "receivedItems.method"}
|
== |
Comparison | Equality check - returns true if values are equal | {"==": [{"var": "age"}, 18]} |
!= |
Comparison | Inequality check - returns true if values are not equal | {"!=": [{"var": "status"}, "active"]} |
> |
Comparison | Greater than - numeric comparison | {">": [{"var": "age"}, 18]} |
>= |
Comparison | Greater than or equal to - numeric comparison | {">=": [{"var": "score"}, 70]} |
< |
Comparison | Less than - numeric comparison | {"<": [{"var": "temperature"}, 38]} |
<= |
Comparison | Less than or equal to - numeric comparison | {"<=": [{"var": "bmi"}, 25]} |
and |
Logic | Logical AND - returns true if all conditions are true | {"and": [{"var": "isAdult"}, {"var": "hasConsent"}]} |
or |
Logic | Logical OR - returns true if any condition is true | {"or": [{"var": "isEmergency"}, {"var": "hasPermission"}]} |
! |
Logic | Logical NOT - negates a value. | {"!": {"var": "isDisabled"}} |
!! |
Logic | Truthy check - returns true if value exists and is truthy | {"!!": {"var": "optionalField"}} |
if |
Conditional | Ternary conditional - if/then/else logic | {"if": [{"var": "isAdult"}, "adult", "minor"]} |
+ |
Arithmetic | Addition - sums numeric values | {"+": [{"var": "score1"}, {"var": "score2"}]} |
- |
Arithmetic | Subtraction - subtracts second value from first | {"-": [{"var": "total"}, {"var": "discount"}]} |
* |
Arithmetic | Multiplication - multiplies numeric values | {"*": [{"var": "price"}, {"var": "quantity"}]} |
/ |
Arithmetic | Division - divides first value by second | {"/": [{"var": "weight"}, {"var": "height"}]} |
% |
Arithmetic | Modulo - returns remainder of division | {"%": [{"var": "number"}, 2]} |
cat |
String | Concatenation - joins strings together | {"cat": [{"var": "firstName"}, " ", {"var": "lastName"}]} |
Implementation:
JsonLogicEvaluator.kt - Core evaluator engineVisibilityEngine.kt - Skip logic using JsonLogicCalculatedValuesEngine.kt - Computed fields using JsonLogicValidationEngine.kt - Custom validation rules using JsonLogicState updates propagate automatically:
Answer Change ā Recalculate Values ā Update Visibility ā Revalidate ā Emit New State| ItemType | Widget | Data Type | Features |
|---|---|---|---|
| STRING | TextInputWidget | String | Single-line text input |
| TEXT | TextInputWidget | String | Multi-line text area |
| BOOLEAN | BooleanWidget | Boolean | Switch/Checkbox toggle |
| DECIMAL | DecimalInputWidget | Double | Numeric keyboard with decimal support |
| INTEGER | IntegerInputWidget | Int | Numeric keyboard for whole numbers |
| DATE | DatePickerWidget | String (ISO) | Platform-native date selection |
| TIME | TimePickerWidget | String (ISO) | Platform-native time selection |
| DATETIME | DateTimePickerWidget | String (ISO) | Combined date and time selection |
| CHOICE | ChoiceWidget | String(s) | Radio buttons, Dropdowns, or Chips |
| OPEN_CHOICE | OpenChoiceWidget | String(s) | Choice with "Other" free-text option |
| DISPLAY | DisplayWidget | N/A | Static text or instructional content |
| GROUP | GroupWidget | N/A | Logical grouping of items, supports repetition |
| QUANTITY | QuantityWidget | Object | Numeric value with associated unit |
| BARCODE | BarcodeScannerWidget | String | Integrated camera barcode scanning (KScan) |
| IMAGE | ImageSelectorWidget | File/Base64 | Image capture or gallery selection (FileKit) |
| ATTACHMENT | AttachmentWidget | File/Base64 | Generic file attachment support (FileKit) |
| LAYOUT_ROW | RowLayoutWidget | N/A | Horizontal arrangement of child widgets |
| LAYOUT_COLUMN | ColumnLayoutWidget | N/A | Vertical arrangement of child widgets |
| LAYOUT_BOX | BoxLayoutWidget | N/A | Stacked or layered arrangement of child widgets |
./gradlew :demo:run./gradlew :demo:installDebugOpen iosDemo/iosDemo.xcodeproj in Xcode and run.
# Run all tests
./gradlew :library:desktopTest
# Run platform-specific tests
./gradlew :library:androidUnitTest
./gradlew :library:iosSimulatorArm64Test# Build library
./gradlew :library:assemble
# Build demo app
./gradlew :demo:assembleDebug| Platform | Status | Min Version |
|---|---|---|
| Android | ā Stable | API 24 (Android 7.0) |
| iOS | ā Stable | iOS 14.0+ |
| Desktop | ā Stable | JVM 11+ |
| Web (WASM) | N/A |
We welcome contributions! Please see CONTRIBUTING.md for guidelines.
./gradlew :library:desktopTest
./gradlew :demo:run
Copyright 2025 LiteQuest Contributors
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
https://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.Based on the LiteQuest Technical Specification v1.0.0, inspired by HL7 FHIR Questionnaire resources.
Special thanks to all contributors.
Made with ā¤ļø by the LiteQuest community