A Kotlin Multiplatform and Compose Multiplatform reference client for Open Health Stack (OHS).
The app renders healthcare UI from configuration rather than hand-written mapping code. Declarative configuration projects FHIR resources into typed view-state, and renderers resolved from a registry then draw that state on screen. This README explains the two halves — extraction and rendering — and then joins them in a single end-to-end example.
This repository is a GitHub template: start your own OHS app from it, or just run it to see OHS in action.
A single Kotlin source tree targets Android, iOS, Desktop (Windows, macOS, Linux), and Web (JS and Wasm). Every platform is built, tested, and released by GitHub Actions; see Deployment.
The desktop app is the fastest path. It needs only JDK 21 — no Android SDK, no Xcode:
git clone <repository-url>
cd player-reference
./gradlew :reference-app:runSample FHIR data is bundled, so the app works out of the box.
Other platforms:
| Target | Command |
|---|---|
| Android | ./gradlew :reference-app:assembleDebug |
| Web (Wasm) | ./gradlew :reference-app:wasmJsBrowserDevelopmentRun |
| Web (JS) | ./gradlew :reference-app:jsBrowserDevelopmentRun |
For iOS, open iosApp/ in Xcode and run, or use the run-configuration widget in a Kotlin Multiplatform IDE.
- On GitHub, click Use this template → Create a new repository (or clone and re-init).
- Set up your machine (Developer setup below) and check that
./gradlew buildpasses. - Make the app yours (Customizing the template below).
- JDK 21
- Android Studio with the Kotlin Multiplatform plugin (Android builds also need the Android SDK)
- Xcode (for iOS builds, macOS only)
- Optional:
kdoctorchecks your multiplatform environment
Use ./gradlew on macOS and Linux, and gradlew.bat on Windows. Run all commands from the repository root.
./gradlew buildCode generation is part of the build: the ig-codegen Gradle plugin runs its generateIgCode task before Kotlin compilation, so there is no separate generation step.
Work through these in rough order:
- Application id / namespace
applicationIdandnamespaceinreference-app/build.gradle.kts- iOS bundle id in
iosApp/Configuration/Config.xcconfig - Kotlin package
dev.ohs.player.reference.appunderreference-app/src/*/kotlin/
- Application name
- Android:
app_nameinstrings.xml - iOS:
PRODUCT_NAMEinConfig.xcconfig - Desktop:
packageNamein thecompose.desktopblock ofreference-app/build.gradle.kts - Web:
<title>inindex.html
- Android:
- Icons
- Android: launcher icons in
reference-app/src/androidMain/res/mipmap-*/ - iOS:
iosApp/iosApp/Assets.xcassets
- Android: launcher icons in
- Generated code package —
packageNamein theigCodegenblock ofreference-app/build.gradle.kts(defaults todev.ohs.player.generated). - Project names —
rootProject.nameand the module name insettings.gradle.kts. Renaming either changes the package of the generated Compose resources class (Res). - Screens and configuration — the sample
Binary-*.jsonfiles underreference-app/src/commonMain/composeResources/files/and the renderers underreference-app/src/commonMain/kotlin/.../feature/. The rest of this README explains how the two fit together.
The app combines the OHS Player library with the OHS Foundational Libraries. Versions are pinned in gradle/libs.versions.toml:
| Library | Purpose |
|---|---|
dev.ohs.player:client |
Config-driven views using flattened data from FHIR resources (the player) |
dev.ohs.fhir:fhir-model |
Typed Kotlin models for FHIR resources |
dev.ohs.fhir:fhir-path |
FHIRPath expression evaluation |
dev.ohs.fhir:fhir-data-capture |
FHIR Structured Data Capture (questionnaires) |
You do not need this template to use OHS Player. The player is a library, not a framework: add it to any Kotlin Multiplatform or Android project and adopt it one screen at a time.
commonMain.dependencies {
implementation("dev.ohs.player:client:1.0.0-alpha01")
}The library README is a standalone user guide. This repository then serves as the worked example.
A screen never consumes a raw FHIR resource. It consumes a typed view-state: a flat, serializable data class with exactly the fields the screen needs. Four steps produce it:
- Author configuration as FHIR
Binaryresources (aViewDefinition, aViewJoinMap, and aViewConfig). - Generate typed Kotlin from those Binaries at build time via the
ig-codegenplugin. - Load the Binaries at runtime through a
ConfigStore. - Extract view-state from a
SearchResultwithGenericStateExtractor.extract<T>().
A ViewDefinition declares the columns of a view as FHIRPath expressions over a FHIR resource. Each column carries a name, a path, and a FHIR type. Excerpt from Binary-PatientSummary.json:
{
"resourceType": "https://sql-on-fhir.org/ig/StructureDefinition/ViewDefinition",
"name": "PatientSummary",
"status": "active",
"resource": "Patient",
"select": [
{
"column": [
{ "name": "patientId", "path": "id", "type": "http://hl7.org/fhir/StructureDefinition/string" },
{ "name": "familyName", "path": "name.family.first()", "type": "http://hl7.org/fhir/StructureDefinition/string" },
{ "name": "gender", "path": "gender", "type": "http://hl7.org/fhir/StructureDefinition/code" },
{ "name": "active", "path": "active", "type": "http://hl7.org/fhir/StructureDefinition/boolean" }
]
}
]
}A ViewJoinMap names the view-state and binds it to a pivot ViewDefinition (and, where needed, joined views). Binary-PatientSummaryState.json:
{
"resourceType": "http://ohs.dev/StructureDefinition/ViewJoinMap",
"name": "patientSummary",
"from": "root",
"resource": "Patient",
"view": "PatientSummary"
}A ViewConfig declares the configuration a renderer accepts, with defaults. Binary-PatientCardConfig.json:
{
"resourceType": "http://ohs.dev/StructureDefinition/ViewConfig",
"viewType": "PatientCard",
"property": [
{ "name": "showStatusChip", "type": "boolean", "valueBoolean": true },
{ "name": "showAge", "type": "boolean", "valueBoolean": true },
{ "name": "elevation", "type": "decimal", "valueDecimal": 2.0 }
]
}A single CodeSystem Binary enumerates the view-types the app renders; see CodeSystem-ViewTypes.json.
The ig-codegen plugin reads these Binaries and emits typed sources. It is applied and configured in reference-app/build.gradle.kts:
plugins {
id("dev.ohs.ig-codegen")
}
igCodegen {
// sourcesDir defaults to src/commonMain/composeResources/files
packageName = "dev.ohs.player.generated"
}Inputs live under src/commonMain/composeResources/files/, organised as states/ (ViewDefinition and ViewJoinMap), configs/ (ViewConfig), and viewtypes/ (the CodeSystem). The generated symbols are:
| Generated symbol | Source | Package |
|---|---|---|
PatientSummaryState and other *State classes |
ViewJoinMap + columns | dev.ohs.player.generated.state |
PatientCardConfig and other *Config classes |
ViewConfig | dev.ohs.player.generated.config |
ViewTypeCS |
CodeSystem | dev.ohs.player.generated.viewtype |
GeneratedConfigManifest |
file listing | dev.ohs.player.generated |
PatientSummaryState, for example, is generated as:
@Serializable
data class PatientSummaryState(
val patientId: String? = null,
val familyName: String? = null,
val givenName: String? = null,
val gender: String? = null,
val birthDate: FhirDate? = null,
val active: Boolean? = null,
val mrn: String? = null,
val phone: String? = null,
)A ConfigStore holds the parsed configuration and is fed by a ConfigSource. The reference app reads the bundled Binaries. To load configuration from a backend instead, the ConfigSource is the only thing to replace. See LocalConfigSource.kt:
object LocalConfigSource : ConfigSource {
private const val DIR_NAME = "states"
override suspend fun readAll(): List<String> =
GeneratedConfigManifest.byDirectory[DIR_NAME].orEmpty().map { fileName ->
Res.readBytes("files/$DIR_NAME/$fileName").decodeToString()
}
}The store and a single extractor are wired once in Extraction.kt:
object Extraction {
private val configStore: ConfigStore = ConfigStore(LocalConfigSource)
val extractor: GenericStateExtractor = GenericStateExtractor(configStore)
}GenericStateExtractor.extract<T>() selects the configuration for T by name, evaluates its FHIRPath columns against a SearchResult, and returns a list of typed T. A SearchResult mirrors a FHIR search response: the pivot resource plus any forward-included and reverse-included resources.
From PatientRepository.kt:
suspend fun getPatients(): List<PatientSummaryState> =
withContext(extractorDispatcher) {
allPatientIds().mapNotNull { id ->
patientSummarySearchResult(id)?.let {
extractor.extract<PatientSummaryState>(it).firstOrNull()
}
}
}The FHIRPath engine holds mutable evaluation state and is not safe for concurrent use, so run all extraction on a single thread. The repository does this with Dispatchers.Default.limitedParallelism(1).
View-state is rendered by renderers resolved through a registry, so screens depend on view-types rather than concrete UI classes:
- Author a
ComponentRendererfor a view-state type. - Register it under a generated
ViewTypeCSview-type in aViewRegistry. - Install the registry into the composition via
LocalViewRegistry. - Render with
ListScaffoldorDetailScaffold, which resolve renderers by view-type.
A ComponentRenderer<T, C> renders one item of state T with configuration C. One renderer class can be registered under several view-types with different configurations.
class PatientCardRenderer : ComponentRenderer<PatientSummaryState, PatientCardConfig> {
@Composable
override fun Render(
item: PatientSummaryState,
config: PatientCardConfig,
options: RenderOptions,
) {
PatientCard(patient = item, config = config, onClick = options.onClick, modifier = options.modifier)
}
}RenderOptions carries the optional tap handler and root modifier. LayoutRenderer<T> is the corresponding arrangement abstraction; the library ships VerticalListRenderer, HorizontalListRenderer, and GridListRenderer.
Group a feature's registrations into an extension on ViewRegistry. See PatientListRegistrations.kt:
fun ViewRegistry.registerPatientList() {
registerComponent<PatientSummaryState, PatientCardConfig>(
ViewTypeCS.PatientCard,
PatientCardRenderer(),
PatientCardConfig(),
)
registerLayout<PatientSummaryState>(
VerticalListRenderer.VIEW_TYPE,
VerticalListRenderer(contentPadding = PaddingValues(16.dp), itemSpacing = 12.dp),
)
}Assemble all feature registrations in one builder, as in AppViewRegistry.kt:
fun buildAppViewRegistry(): ViewRegistry = ViewRegistry().apply {
registerPatientList()
registerPatientProfile()
}A registry lookup is keyed by both view-type and state type. If no renderer was registered for that pair, the lookup throws NoSuchElementException naming the missing key.
Provide the registry at the composition root so every screen can resolve renderers. See App.kt:
@Composable
fun App() {
val registry = remember { buildAppViewRegistry() }
CompositionLocalProvider(LocalViewRegistry provides registry) {
MaterialTheme {
// NavHost, screens, etc.
}
}
}ListScaffold renders a list. In its builder, component(...) and layout(...) name the view-types to resolve; omitting layout(...) falls back to VerticalListRenderer, and an empty list renders emptyState without invoking the layout renderer. See PatientListScreen.kt:
ListScaffold<PatientSummaryState>(
items = patients,
onItemClick = { onPatientClick(it.patientId ?: "") },
key = { it.patientId ?: it.hashCode().toString() },
) {
component(ViewTypeCS.PatientCard)
layout(VerticalListRenderer.VIEW_TYPE)
topBar { TopAppBar(title = { Text("Patients") }) }
emptyState { Text("No patients") }
}DetailScaffold is the single-item counterpart: it renders a stack of sections for one nullable item, falling back to a notFound slot when the item is absent.
A patient list screen exercises both halves of the pipeline:
- Configuration.
Binary-PatientSummary.jsondeclares the columns;Binary-PatientSummaryState.jsonnames thepatientSummaryview-state.ig-codegengeneratesPatientSummaryState. - Extraction.
PatientRepository.getPatients()builds aSearchResultper patient and callsextractor.extract<PatientSummaryState>(result), yieldingList<PatientSummaryState>. - Registration.
registerPatientList()bindsPatientCardRenderertoViewTypeCS.PatientCardforPatientSummaryState, andbuildAppViewRegistry()installs it at the composition root. - Rendering.
PatientListScreenpasses the extracted states toListScaffold, which resolvesPatientCardby view-type and renders each row.
Adding a field is a configuration change: add a column to the ViewDefinition, then reference the regenerated state field in the renderer. No extraction or wiring code changes.
./gradlew :reference-app:allTests # all platforms
./gradlew :reference-app:jvmTest # JVM onlyThe ci.yml workflow validates every pull request and push to main. It runs formatting (spotless), JVM tests, Android lint, JS/Wasm compilation, and iOS compile-and-link, each as a separate job.
Pushing a semantic version tag (vX.Y.Z or vX.Y.Z-suffix) triggers the release.yml workflow. It builds and signs every platform, then publishes a GitHub Release with checksummed artifacts:
- Android APK (
assembleRelease) - Desktop installers: Linux
.deband.rpm, Windows.msi, macOS.dmg - A portable Linux tarball (
createDistributable)
A workflow_dispatch run is a dry run: it builds, signs, and uploads artifacts but does not publish a Release. The web (Wasm) and GitHub Pages jobs are currently gated off (if: false) pending a larger build runner; the web preview is deployed manually in the interim.
Build a native installer or distributable locally:
./gradlew :reference-app:packageDmg # macOS .dmg
./gradlew :reference-app:packageMsi # Windows .msi
./gradlew :reference-app:packageDeb # Linux .deb
./gradlew :reference-app:createDistributable # portable app image
./gradlew :reference-app:wasmJsBrowserDistribution # web bundleRelease builds read signing inputs from environment variables first, then fall back to a keystore.properties file for development. To sign a release locally:
cp keystore.properties.template keystore.properties
# Edit keystore.properties with your keystore path, alias, and passwords, then:
./gradlew :reference-app:bundleReleasekeystore.properties is gitignored and must never be committed. When both are set, the environment variables ANDROID_KEYSTORE_PATH, ANDROID_KEY_ALIAS, ANDROID_KEY_PASSWORD, and ANDROID_STORE_PASSWORD take precedence over the file. If neither is configured, release builds are emitted unsigned.
Learn more about Kotlin Multiplatform, Compose Multiplatform, and Kotlin/Wasm.