A Kotlin Multiplatform and Compose Multiplatform reference client for Open Health Stack (OHS). A single Kotlin source tree targets Android, iOS, JVM desktop, JS browser, and Wasm browser.
The application renders healthcare UI from configuration rather than hand-written mapping code. FHIR resources are projected into typed view-state by declarative configuration, and that state is rendered by renderers resolved through a registry. The two halves — extraction and rendering — are described below, then joined in a single end-to-end example.
- JDK 21
- Android SDK (for Android builds)
- Xcode (for iOS builds, macOS only)
Use ./gradlew on macOS and Linux, and gradlew.bat on Windows. All commands run from the repository root.
git clone <repository-url>
cd ohs-player-reference-client-app
./gradlew buildCode generation is part of compilation. The ig-codegen Gradle plugin runs its generateIgCode task automatically before Kotlin compilation, so there is no separate generation step.
| Target | Command |
|---|---|
| Android | ./gradlew :ohs-player-reference-app:assembleDebug |
| Desktop (JVM) | ./gradlew :ohs-player-reference-app:run |
| Web (Wasm) | ./gradlew :ohs-player-reference-app:wasmJsBrowserDevelopmentRun |
| Web (JS) | ./gradlew :ohs-player-reference-app:jsBrowserDevelopmentRun |
For iOS, open iosApp/ in Xcode and run, or use the run-configuration widget in a Kotlin Multiplatform IDE.
A screen never consumes a raw FHIR resource. It consumes a typed view-state — a flat, serializable data class containing exactly the fields the screen needs. View-state is produced by a configuration-driven pipeline:
- 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 ohs-player-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, fed by a ConfigSource. The reference app reads the bundled Binaries; replacing this with a network fetch is the only change required to load configuration from a backend. 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 carries the pivot resource plus any forward-included and reverse-included resources, mirroring a FHIR search response.
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. Serialize extraction onto 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, and throws NoSuchElementException naming the missing key if a renderer was not registered.
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; component(...) and layout(...) name the view-types to resolve. An empty list short-circuits to emptyState without invoking the layout renderer, and omitting layout(...) falls back to VerticalListRenderer. 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.
Run all multiplatform tests:
./gradlew :ohs-player-library:allTests :ohs-player-reference-app:allTestsRun JVM tests only:
./gradlew :ohs-player-library:jvmTest :ohs-player-reference-app:jvmTestReleases are produced by the release.yml GitHub Actions workflow, triggered by a semantic version tag (vX.Y.Z or vX.Y.Z-suffix). The workflow 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 performs a dry run: it builds, signs, and uploads artifacts without publishing 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 :ohs-player-reference-app:packageDmg # macOS .dmg
./gradlew :ohs-player-reference-app:packageMsi # Windows .msi
./gradlew :ohs-player-reference-app:packageDeb # Linux .deb
./gradlew :ohs-player-reference-app:createDistributable # portable app image
./gradlew :ohs-player-reference-app:wasmJsBrowserDistribution # web bundleRelease builds read signing inputs from environment variables first, then from a keystore.properties file as a development fallback. To produce a signed release locally:
cp keystore.properties.template keystore.properties
# Edit keystore.properties with your keystore path, alias, and passwords, then:
./gradlew :ohs-player-reference-app:bundleReleasekeystore.properties is gitignored and must never be committed. The environment variables ANDROID_KEYSTORE_PATH, ANDROID_KEY_ALIAS, ANDROID_KEY_PASSWORD, and ANDROID_STORE_PASSWORD take precedence over the file when both are set. If neither is configured, release builds are emitted unsigned.
Learn more about Kotlin Multiplatform, Compose Multiplatform, and Kotlin/Wasm.