Skip to content

ellykits/lite-quest

Repository files navigation

LiteQuest

Maven Central Version License Kotlin Platform

A lightweight, FHIR-inspired questionnaire library for Kotlin Multiplatform applications.

This library is 🚧 work in progress and not production ready.

Installation

Add to your build.gradle.kts:

kotlin {
    sourceSets {
        commonMain {
            dependencies {
                implementation("io.github.ellykits.litequest:litequest-library:1.0.0-beta01")
            }
        }
    }
}

Usage

Basic Questionnaire

@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,
    )
}

Paginated Questionnaires

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 */ }
)

QuestionnaireScreen Parameters

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

Embedded Questionnaire

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.

JsonLogic Expressions

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") })
        })
    }
)

Custom Widgets

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 */ }
)

JsonLogic Extensibility

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.

Architecture

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 utilities

Key Concepts

JsonLogic Expressions

LiteQuest 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 engine
  • VisibilityEngine.kt - Skip logic using JsonLogic
  • CalculatedValuesEngine.kt - Computed fields using JsonLogic
  • ValidationEngine.kt - Custom validation rules using JsonLogic

Reactive State Management

State updates propagate automatically:

Answer Change → Recalculate Values → Update Visibility → Revalidate → Emit New State

Widget Types

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

Running the Demo

Desktop

./gradlew :demo:desktopDemo:desktopRun

Android

./gradlew :demo:androidDemo:installDebug

Web (JS)

./gradlew :demo:webDemo:jsBrowserDevelopmentRun

Web (Wasm)

./gradlew :demo:webDemo:wasmJsBrowserDevelopmentRun

iOS

Open demo/iosDemo/iosDemo.xcodeproj in Xcode and run on a simulator or device.

Development

Running Tests

# Run all tests
./gradlew :library:allTests

# Run platform-specific tests
./gradlew :library:desktopTest
./gradlew :library:jsBrowserTest
./gradlew :library:androidUnitTest
./gradlew :library:iosSimulatorArm64Test

Building

# Build library
./gradlew :library:build

# Build Android demo
./gradlew :demo:androidDemo:assembleDebug

Platform Support

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

Documentation

Contributing

We welcome contributions! Please see CONTRIBUTING.md for guidelines.

Development Setup

  1. Clone the repository
  2. Open in IntelliJ IDEA or Android Studio
  3. Run tests: ./gradlew :library:allTests
  4. Run demo: ./gradlew :demo:desktopDemo:desktopRun

Code Style

License

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.

Acknowledgments

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

About

A lightweight, FHIR-inspired questionnaire (form) library for Kotlin Multiplatform applications.

Resources

License

Contributing

Stars

Watchers

Forks

Packages

 
 
 

Contributors