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

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

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

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

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 - 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 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:run

Android

./gradlew :demo:installDebug

iOS

Open iosDemo/iosDemo.xcodeproj in Xcode and run.

Development

Running Tests

# Run all tests
./gradlew :library:desktopTest

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

Building

# Build library
./gradlew :library:assemble

# Build demo app
./gradlew :demo:assembleDebug

Platform Support

Platform Status Min Version
Android ✅ Stable API 24 (Android 7.0)
iOS ✅ Stable iOS 14.0+
Desktop ✅ Stable JVM 11+
Web (WASM) ⚠️ Disabled in current build N/A

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:desktopTest
  4. Run demo: ./gradlew :demo:run

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