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-beta01")
}
}
}
}@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 */ },
showCloseButton = true,
)
}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 */ }
)| Parameter | Type | Default | Description |
|---|---|---|---|
type |
QuestionnaireType |
— | Single(questionnaire) or Paginated(paginatedQuestionnaire) |
manager |
QuestionnaireManager |
— | Reactive state manager |
onSubmit |
() -> Unit |
— | Called when the user submits the form |
modifier |
Modifier |
Modifier |
Applied to the root composable |
mode |
QuestionnaireMode |
Edit |
Edit, Summary, or ReadOnly |
onModeChange |
((QuestionnaireMode) -> Unit)? |
— | Required for Review/Edit toggle; pass null to hide the button |
onDismiss |
(() -> Unit)? |
null |
Called when the close button is tapped |
showCloseButton |
Boolean |
false |
Show the X close button in the top bar (visible in all modes including Summary) |
showDismissDialogOnClose |
Boolean |
true |
Show a confirmation dialog before dismissing in Edit mode |
showValidationDialogOnSubmit |
Boolean |
true |
Show a dialog listing validation errors when the user submits |
showReview |
Boolean |
true |
Show the Review/Edit toggle button |
allowSubmitWithErrors |
Boolean |
false |
Allow submission when validation errors exist. When false, the validation dialog hides the "Submit anyway" button and the submit action is blocked |
customActions |
(@Composable () -> Unit)? |
null |
Replace the default Submit/Previous/Next bottom bar with a custom composable |
EmbeddedQuestionnaire renders only the form fields — no Scaffold, no TopAppBar, no submit button, no dismiss dialogs. It sizes to fit its items, so the host screen owns the layout, scroll, and submission logic. Validation errors still appear inline as the user interacts with each field.
Read answers at any time via manager.state:
val response = manager.state.value.response@Composable
fun EncounterScreen(onBack: () -> Unit) {
val questionnaire = remember { myQuestionnaire() }
val manager = remember { QuestionnaireManager(questionnaire, LiteQuestEvaluator(questionnaire)) }
var clinicianName by remember { mutableStateOf("") }
var submitted by remember { mutableStateOf(false) }
Scaffold(
topBar = { /* your top bar */ },
) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.verticalScroll(rememberScrollState()),
) {
// Form fields appear here — sized to content, no internal scroll
EmbeddedQuestionnaire(
manager = manager,
modifier = Modifier.fillMaxWidth(),
)
// External inputs that are not part of the questionnaire
OutlinedTextField(
value = clinicianName,
onValueChange = { clinicianName = it },
label = { Text("Attending Clinician") },
)
// Custom submit — reads questionnaire answers from manager state
Button(onClick = {
val response = manager.state.value.response
// combine response with clinicianName and submit
}) {
Text("Record Encounter")
}
}
}
}QuestionnaireScreen remains the full-screen component that manages its own Scaffold, system bar insets, submit flow, and dismiss dialogs. Use EmbeddedQuestionnaire when you want to own all of that yourself.
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 a 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 submitting */ }
)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 (full-screen), EmbeddedQuestionnaire (fields only)
│ ├── 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 JsonLogic
State 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:desktopDemo:desktopRun./gradlew :demo:androidDemo:installDebug./gradlew :demo:webDemo:jsBrowserDevelopmentRun./gradlew :demo:webDemo:wasmJsBrowserDevelopmentRunOpen demo/iosDemo/iosDemo.xcodeproj in Xcode and run on a simulator or device.
# Run all tests
./gradlew :library:allTests
# Run platform-specific tests
./gradlew :library:desktopTest
./gradlew :library:jsBrowserTest
./gradlew :library:androidUnitTest
./gradlew :library:iosSimulatorArm64Test# Build library
./gradlew :library:build
# Build Android demo
./gradlew :demo:androidDemo:assembleDebug| Platform | Status | Min Version |
|---|---|---|
| Android | ✅ Stable | API 24 (Android 7.0) |
| iOS | ✅ Stable | iOS 14.0+ |
| Desktop | ✅ Stable | JVM 11+ |
| Web (JS) | ✅ Stable | — |
| Web (WasmJS) | ✅ Stable | — |
- LiteQuest Technical Specification v1.0.0 - Core engine architecture and JsonLogic evaluation
We welcome contributions! Please see CONTRIBUTING.md for guidelines.
- Clone the repository
- Open in IntelliJ IDEA or Android Studio
- Run tests:
./gradlew :library:allTests - Run demo:
./gradlew :demo:desktopDemo:desktopRun
- Follow Kotlin Coding Conventions
- Use meaningful names
- Keep functions small and focused
- Write tests for new features
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