Skip to content

Repository files navigation

OHS Player Reference Client App

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.

Supported platforms

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.

Getting started

Just want to see OHS in action?

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

Sample 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.

Start from this template

  1. On GitHub, click Use this template → Create a new repository (or clone and re-init).
  2. Set up your machine (Developer setup below) and check that ./gradlew build passes.
  3. Make the app yours (Customizing the template below).

Developer setup (Kotlin Multiplatform)

  • JDK 21
  • Android Studio with the Kotlin Multiplatform plugin (Android builds also need the Android SDK)
  • Xcode (for iOS builds, macOS only)
  • Optional: kdoctor checks your multiplatform environment

Use ./gradlew on macOS and Linux, and gradlew.bat on Windows. Run all commands from the repository root.

Build

./gradlew build

Code 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.

Customizing the template

Work through these in rough order:

  • Application id / namespace
  • Application name
    • Android: app_name in strings.xml
    • iOS: PRODUCT_NAME in Config.xcconfig
    • Desktop: packageName in the compose.desktop block of reference-app/build.gradle.kts
    • Web: <title> in index.html
  • Icons
    • Android: launcher icons in reference-app/src/androidMain/res/mipmap-*/
    • iOS: iosApp/iosApp/Assets.xcassets
  • Generated code packagepackageName in the igCodegen block of reference-app/build.gradle.kts (defaults to dev.ohs.player.generated).
  • Project namesrootProject.name and the module name in settings.gradle.kts. Renaming either changes the package of the generated Compose resources class (Res).
  • Screens and configuration — the sample Binary-*.json files under reference-app/src/commonMain/composeResources/files/ and the renderers under reference-app/src/commonMain/kotlin/.../feature/. The rest of this README explains how the two fit together.

Bundled OHS libraries

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)

Have an existing app?

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.

From FHIR data to view state

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:

  1. Author configuration as FHIR Binary resources (a ViewDefinition, a ViewJoinMap, and a ViewConfig).
  2. Generate typed Kotlin from those Binaries at build time via the ig-codegen plugin.
  3. Load the Binaries at runtime through a ConfigStore.
  4. Extract view-state from a SearchResult with GenericStateExtractor.extract<T>().

1. Author configuration

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.

2. Generate typed Kotlin

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

3. Load configuration at runtime

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

4. Extract view-state

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).

Rendering view state

View-state is rendered by renderers resolved through a registry, so screens depend on view-types rather than concrete UI classes:

  1. Author a ComponentRenderer for a view-state type.
  2. Register it under a generated ViewTypeCS view-type in a ViewRegistry.
  3. Install the registry into the composition via LocalViewRegistry.
  4. Render with ListScaffold or DetailScaffold, which resolve renderers by view-type.

1. Author a renderer

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.

2. Register renderers

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.

3. Install the registry

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.
        }
    }
}

4. Render

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.

End-to-end example

A patient list screen exercises both halves of the pipeline:

  1. Configuration. Binary-PatientSummary.json declares the columns; Binary-PatientSummaryState.json names the patientSummary view-state. ig-codegen generates PatientSummaryState.
  2. Extraction. PatientRepository.getPatients() builds a SearchResult per patient and calls extractor.extract<PatientSummaryState>(result), yielding List<PatientSummaryState>.
  3. Registration. registerPatientList() binds PatientCardRenderer to ViewTypeCS.PatientCard for PatientSummaryState, and buildAppViewRegistry() installs it at the composition root.
  4. Rendering. PatientListScreen passes the extracted states to ListScaffold, which resolves PatientCard by 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.

Testing

./gradlew :reference-app:allTests   # all platforms
./gradlew :reference-app:jvmTest    # JVM only

Deployment

Continuous integration

The 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.

Release pipeline

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 .deb and .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.

Local installers

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 bundle

Android release signing

Release 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:bundleRelease

keystore.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.

Releases

Packages

Contributors

Languages