October 2, 2026

Compose Multiplatform tutorial: one app on Android, iOS, desktop

Build a reading-list app for Android, iOS and desktop with Compose Multiplatform 1.12.1: ViewModel, Navigation, Ktor, Room, Koin and Hot Reload.

Recruiting

Tech

Mobile

This Compose Multiplatform tutorial builds one reading-list app that runs on Android, iOS and desktop from a single Kotlin codebase, on JetBrains' Compose Multiplatform 1.12 release and Kotlin 2.4.20. JetBrains' first-app tutorial stops at UI, resources and Hot Reload. This one keeps going: a ViewModel, Navigation, a live Ktor call, a Room database and Koin, with a runnable checkpoint after every step. If you're still deciding whether the iOS target is ready for production, read Compose Multiplatform: what's stable enough to ship first.

What you'll build

A reading-list app. You search Open Library for books, open a detail screen, and favourite books into a local Room database that survives a restart. The steps use Kotlin only. Swift appears once, in the ContentView.swift file the template's iOS app already contains, and you don't edit it.

This is the finished project layout (template files you never touch are left out):

text
ReadingList/
├── gradle/
│   └── libs.versions.toml
├── build.gradle.kts
├── settings.gradle.kts
├── androidApp/
│   └── src/main/AndroidManifest.xml          # adds the INTERNET permission
├── desktopApp/
│   └── src/main/kotlin/compose/project/demo/
│       └── main.kt                           # desktop window
├── iosApp/                                   # from the template, not edited
└── shared/
    ├── build.gradle.kts
    └── src/
        ├── commonMain/kotlin/compose/project/demo/
        │   ├── App.kt                        # Koin and NavHost
        │   ├── Screens.kt                    # list and detail UI
        │   ├── ListViewModel.kt
        │   ├── DetailViewModel.kt
        │   ├── Book.kt
        │   ├── BookApi.kt                    # Ktor client
        │   ├── BookRepository.kt
        │   ├── Database.kt                   # Room entity, DAO, database
        │   ├── AppModule.kt                  # Koin module
        │   └── PlatformModule.kt             # expect declaration
        ├── androidMain/kotlin/compose/project/demo/
        │   └── PlatformModule.android.kt
        ├── iosMain/kotlin/compose/project/demo/
        │   └── PlatformModule.ios.kt
        └── jvmMain/kotlin/compose/project/demo/
            └── PlatformModule.jvm.kt

After a search for kotlin, the list screen shows 20 books, each with its authors and a Favourite button. A counter above the search box tracks how many you've saved, and a saved book's button reads Remove instead.

What you need before you start

  • A Mac if you want the iOS target. On Windows or Linux you can still build the Android and desktop targets and skip step 8 (not tested on Windows or Linux).
  • Git, to clone the template in step 1.
  • IntelliJ IDEA 2025.2.2 or later, or Android Studio Otter 2025.2.1 or later, with the Kotlin Multiplatform plugin, to open and edit the project. Those are the IDE versions the Hot Reload page lists.
  • Xcode, launched once so it finishes installing its components. The Kotlin Multiplatform quickstart also asks for the ANDROID_HOME variable and has a preflight checks window in the IDE that flags what's missing.
  • Kotlin and Compose Multiplatform basics, and a rough idea of coroutines.
  • A macOS or Linux shell. The commands use rm -rf and ./gradlew, so Windows users should work in WSL.

The versions line up like this. Kotlin 2.4.20, released on 7 September 2026, is the latest stable per Kotlin's release list, and JetBrains' compatibility table asks for Kotlin 2.1.0 or later, with 2.2.20 recommended for iOS and web. The multiplatform compatibility guide lists Gradle 7.6.3 to 9.7.0, AGP 8.5.2 to 9.3.1 and Xcode 26.4 for Kotlin 2.4.20. We built this project on Xcode 27.0 and it compiled, but that's our observation, not a vendor statement. If you want the other changes in that Kotlin release, what changed in Kotlin 2.4.20 covers them.

On the JDK: the template configures a JDK 21 for the Gradle daemon, so you don't need to install one to build. Hot Reload runs the app on the JetBrains Runtime, which the build downloads for you (JBR 25 in our build). Per the Hot Reload docs, that automatic provisioning is experimental and needs the Foojay resolver plugin, which the template's settings.gradle.kts includes.

Check your machine from a terminal:

Bash
git --version
xcodebuild -version
echo $ANDROID_HOME

Quickstart

Want to see a window before you read anything else? Clone JetBrains' template and run its desktop app:

Bash
git clone https://github.com/kotlin-hands-on/get-started-with-cm ReadingList
cd ReadingList
git checkout ca0b6d2
./gradlew :desktopApp:run

The untouched template opens JetBrains' sample "Local Time App" window. Three things to get right before you build on it:

  • Start from the pinned commit and keep the versions in the template's gradle/libs.versions.toml. Don't bump one library on its own.
  • Run ./gradlew :desktopApp:hotRun --auto for UI work. You'll use it in step 2.
  • Keep all three Gradle modules the template has (shared, androidApp, desktopApp) plus iosApp. The UI and logic live in shared.

How to build a Compose Multiplatform app

1. Clone the template and add every dependency

Clone JetBrains' template at a pinned commit, trim what the tutorial doesn't use, then add every library and plugin in this one step. Nothing gets added to Gradle after it.

The template is the get-started-with-cm repository, the same project JetBrains' new-project guide uses. Pinning commit ca0b6d2 (1 October 2026) keeps the files you get identical to the ones this tutorial was built on. In a terminal:

Bash
git clone https://github.com/kotlin-hands-on/get-started-with-cm ReadingList
cd ReadingList
git checkout ca0b6d2

The template has a web app and tests this tutorial doesn't use. Remove them, along with the sample Greeting code, from the project root:

Bash
rm -rf webApp kotlin-js-store
rm -rf shared/src/jsMain shared/src/wasmJsMain shared/src/webTest
rm -rf shared/src/commonTest shared/src/androidHostTest shared/src/iosTest shared/src/jvmTest
rm shared/src/commonMain/kotlin/compose/project/demo/Greeting.kt
rm shared/src/commonMain/kotlin/compose/project/demo/GreetingUtil.kt

In settings.gradle.kts, delete the line include(":webApp") and set the project name by changing the rootProject.name line to:

Kotlin
rootProject.name = "ReadingList"

The project has shared, androidApp, desktopApp and iosApp, plus gradle/libs.versions.toml. Keep the template's package, compose.project.demo, in every path and file below. Leave MainActivity.kt, MainViewController.kt, the Platform*.kt files, the composeResources folder and iosApp/ as they are. Why a separate androidApp next to shared? The Android KMP library plugin page says there's no direct replacement for configuring a KMP module with com.android.application, and tells you to extract the Android app into its own module.

The template's versions are AGP 9.1.1, Compose Multiplatform 1.12.1, Kotlin 2.4.20, lifecycle 2.11.0, coroutines 1.11.0 and Gradle 9.5.1. Material 3 is 1.12.0-alpha03 in its catalog. It's an alpha, and it's what the template ships, so keep it.

Replace gradle/libs.versions.toml with this. The new lines are the six entries at the end of [versions], the libraries from room3-runtime down, and the three plugins at the end:

toml
[versions]
agp = "9.1.1"
android-compileSdk = "37"
android-minSdk = "24"
android-targetSdk = "37"
androidx-activity = "1.13.0"
androidx-appcompat = "1.8.0"
androidx-core = "1.19.0"
androidx-espresso = "3.7.0"
androidx-lifecycle = "2.11.0"
androidx-testExt = "1.3.0"
composeMultiplatform = "1.12.1"
junit = "4.13.2"
kotlin = "2.4.20"
kotlinx-coroutines = "1.11.0"
material3 = "1.12.0-alpha03"
ksp = "2.3.12"
room3 = "3.0.3"
androidx-sqlite = "2.7.1"
ktor = "3.6.0"
navigation = "2.10.0-beta01"
koin = "4.2.2"

[libraries]
kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" }
kotlin-testJunit = { module = "org.jetbrains.kotlin:kotlin-test-junit", version.ref = "kotlin" }
junit = { module = "junit:junit", version.ref = "junit" }
androidx-core-ktx = { module = "androidx.core:core-ktx", version.ref = "androidx-core" }
androidx-testExt-junit = { module = "androidx.test.ext:junit", version.ref = "androidx-testExt" }
androidx-espresso-core = { module = "androidx.test.espresso:espresso-core", version.ref = "androidx-espresso" }
androidx-appcompat = { module = "androidx.appcompat:appcompat", version.ref = "androidx-appcompat" }
androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidx-activity" }
compose-uiTooling = { module = "org.jetbrains.compose.ui:ui-tooling", version.ref = "composeMultiplatform" }
androidx-lifecycle-viewmodelCompose = { module = "org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "androidx-lifecycle" }
androidx-lifecycle-runtimeCompose = { module = "org.jetbrains.androidx.lifecycle:lifecycle-runtime-compose", version.ref = "androidx-lifecycle" }
compose-runtime = { module = "org.jetbrains.compose.runtime:runtime", version.ref = "composeMultiplatform" }
compose-foundation = { module = "org.jetbrains.compose.foundation:foundation", version.ref = "composeMultiplatform" }
compose-material3 = { module = "org.jetbrains.compose.material3:material3", version.ref = "material3" }
compose-ui = { module = "org.jetbrains.compose.ui:ui", version.ref = "composeMultiplatform" }
compose-components-resources = { module = "org.jetbrains.compose.components:components-resources", version.ref = "composeMultiplatform" }
compose-uiToolingPreview = { module = "org.jetbrains.compose.ui:ui-tooling-preview", version.ref = "composeMultiplatform" }
kotlinx-coroutinesSwing = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-swing", version.ref = "kotlinx-coroutines" }
room3-runtime = { module = "androidx.room3:room3-runtime", version.ref = "room3" }
room3-compiler = { module = "androidx.room3:room3-compiler", version.ref = "room3" }
androidx-sqlite-bundled = { module = "androidx.sqlite:sqlite-bundled", version.ref = "androidx-sqlite" }
ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" }
ktor-client-okhttp = { module = "io.ktor:ktor-client-okhttp", version.ref = "ktor" }
ktor-client-darwin = { module = "io.ktor:ktor-client-darwin", version.ref = "ktor" }
ktor-client-contentNegotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktor" }
ktor-serialization-kotlinxJson = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" }
navigation-compose = { module = "org.jetbrains.androidx.navigation:navigation-compose", version.ref = "navigation" }
koin-bom = { module = "io.insert-koin:koin-bom", version.ref = "koin" }
koin-core = { module = "io.insert-koin:koin-core" }
koin-compose = { module = "io.insert-koin:koin-compose" }
koin-compose-viewmodel = { module = "io.insert-koin:koin-compose-viewmodel" }

[plugins]
androidApplication = { id = "com.android.application", version.ref = "agp" }
androidMultiplatformLibrary = { id = "com.android.kotlin.multiplatform.library", version.ref = "agp" }
composeMultiplatform = { id = "org.jetbrains.compose", version.ref = "composeMultiplatform" }
composeCompiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
kotlinJvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" }
kotlinMultiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" }
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
room3 = { id = "androidx.room3", version.ref = "room3" }
kotlinSerialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }

The Room 3 and KSP choices are deliberate. The template doesn't know about either, and the vendor pages disagree with each other, so these pins come from checking the release pages: Room 3.0.3 is the stable release (the Room docs sample still shows 3.1.0-alpha01), and KSP 2.3.12 is the latest (9 September 2026).

Replace the root build.gradle.kts. The three new lines are at the bottom of the block, and apply false follows the template's convention:

Kotlin
plugins {
    alias(libs.plugins.androidApplication) apply false
    alias(libs.plugins.androidMultiplatformLibrary) apply false
    alias(libs.plugins.composeMultiplatform) apply false
    alias(libs.plugins.composeCompiler) apply false
    alias(libs.plugins.kotlinJvm) apply false
    alias(libs.plugins.kotlinMultiplatform) apply false
    alias(libs.plugins.ksp) apply false
    alias(libs.plugins.room3) apply false
    alias(libs.plugins.kotlinSerialization) apply false
}

Replace shared/build.gradle.kts. Ktor needs a different HTTP engine per target: OkHttp for Android and the JVM, Darwin for iOS. Room's compiler runs through KSP once per target.

Kotlin
import org.jetbrains.kotlin.gradle.dsl.JvmTarget

plugins {
    alias(libs.plugins.kotlinMultiplatform)
    alias(libs.plugins.androidMultiplatformLibrary)
    alias(libs.plugins.composeMultiplatform)
    alias(libs.plugins.composeCompiler)
    alias(libs.plugins.ksp)
    alias(libs.plugins.room3)
    alias(libs.plugins.kotlinSerialization)
}

kotlin {
    listOf(
        iosArm64(),
        iosSimulatorArm64()
    ).forEach { iosTarget ->
        iosTarget.binaries.framework {
            baseName = "Shared"
            isStatic = true
        }
    }

    jvm()

    android {
        namespace = "compose.project.demo.shared"
        compileSdk = libs.versions.android.compileSdk.get().toInt()
        minSdk = libs.versions.android.minSdk.get().toInt()

        compilerOptions {
            jvmTarget = JvmTarget.JVM_11
        }
        androidResources {
            enable = true
        }
    }

    sourceSets {
        androidMain.dependencies {
            implementation(libs.ktor.client.okhttp)
            implementation(libs.compose.uiToolingPreview)
            implementation(libs.compose.uiTooling)
        }
        commonMain.dependencies {
            implementation(libs.compose.runtime)
            implementation(libs.compose.foundation)
            implementation(libs.compose.material3)
            implementation(libs.compose.ui)
            implementation(libs.compose.components.resources)
            implementation(libs.compose.uiToolingPreview)
            implementation(libs.androidx.lifecycle.viewmodelCompose)
            implementation(libs.androidx.lifecycle.runtimeCompose)
            implementation(libs.room3.runtime)
            implementation(libs.androidx.sqlite.bundled)
            implementation(libs.ktor.client.core)
            implementation(libs.ktor.client.contentNegotiation)
            implementation(libs.ktor.serialization.kotlinxJson)
            implementation(libs.navigation.compose)
            implementation(project.dependencies.platform(libs.koin.bom))
            implementation(libs.koin.core)
            implementation(libs.koin.compose)
            implementation(libs.koin.compose.viewmodel)
        }
        jvmMain.dependencies {
            implementation(libs.ktor.client.okhttp)
        }
        iosMain.dependencies {
            implementation(libs.ktor.client.darwin)
        }
    }
}

room3 {
    schemaDirectory("$projectDir/schemas")
}

dependencies {
    androidRuntimeClasspath(libs.compose.uiTooling)
    add("kspAndroid", libs.room3.compiler)
    add("kspJvm", libs.room3.compiler)
    add("kspIosArm64", libs.room3.compiler)
    add("kspIosSimulatorArm64", libs.room3.compiler)
}

Three notes where this differs from what the docs show. The Room docs page configures its Gradle plugin with room { schemaDirectory(...) }. With plugin androidx.room3 3.0.3 that fails with Unresolved reference 'room', and the extension is named room3. The same page lists a kspIosX64 configuration. The template has no iosX64 target, so there's no such configuration here, and the four add(...) lines cover the four targets that exist. And the KSP multiplatform docs say the catch-all ksp(...) configuration has been deprecated since KSP 2, which is why each target gets its own.

The app calls the internet, and Android blocks that unless the manifest asks for it. In androidApp/src/main/AndroidManifest.xml, insert this line directly above the <application tag:

text
    <uses-permission android:name="android.permission.INTERNET" />

The template's App.kt needs kotlinx-datetime and flag images, which the new catalog drops. Replace shared/src/commonMain/kotlin/compose/project/demo/App.kt with a minimal placeholder screen:

Kotlin
package compose.project.demo

import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeContentPadding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp

@Composable
fun App() {
    MaterialTheme {
        BookListScreen()
    }
}

@Composable
fun BookListScreen() {
    Column(Modifier.fillMaxSize().safeContentPadding().padding(16.dp)) {
        Text("Reading List", style = MaterialTheme.typography.headlineMedium)
    }
}

Checkpoint, from the project root:

Bash
./gradlew :shared:compileKotlinJvm

You should see something like (the time will differ):

text
BUILD SUCCESSFUL in 1m 12s

Then run the desktop app:

Bash
./gradlew :desktopApp:run

A desktop window opens showing the text "Reading List". Its title bar still reads "Local Time App" until step 2 replaces main.kt.

If you see Unresolved reference 'room', the block in shared/build.gradle.kts still says room instead of room3. Close the window before you continue.

2. See an edit land with Hot Reload

Replace the desktop entry point, start the app with Hot Reload and watch a string change without a restart.

Replace desktopApp/src/main/kotlin/compose/project/demo/main.kt. The alwaysOnTop = true line keeps the window above your editor while you iterate:

Kotlin
package compose.project.demo

import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Window
import androidx.compose.ui.window.WindowPosition
import androidx.compose.ui.window.application
import androidx.compose.ui.window.rememberWindowState

fun main() = application {
    val state = rememberWindowState(
        size = DpSize(480.dp, 720.dp),
        position = WindowPosition(300.dp, 100.dp)
    )
    Window(
        title = "Reading List",
        onCloseRequest = ::exitApplication,
        state = state,
        alwaysOnTop = true
    ) {
        App()
    }
}

The Hot Reload plugin is already on. Per the Hot Reload page, from Compose Multiplatform 1.10.0 it's bundled and enabled by default for any project with a desktop target. In a terminal, from the project root:

Bash
./gradlew :desktopApp:hotRun --auto

--auto reloads on save. Without it, run ./gradlew :desktopApp:reload from a second terminal. hotRun is the current task name; in our build, ./gradlew :desktopApp:tasks --all listed runHot as deprecated.

Now the checkpoint. With the window open, change the heading string in shared/src/commonMain/kotlin/compose/project/demo/App.kt from "Reading List" to "My reading list" and save the file.

The window text changes from "Reading List" to "My reading list" a moment after you save.

The window text should change without the window closing or the app restarting. If nothing changes, check that you passed --auto. Change the string back to "Reading List" before you continue. Step 3 replaces the file anyway.

3. Add the list ViewModel

Add a Book model, a ListViewModel that holds the list as state, and a screen that renders it. The data is hard-coded sample books for now, so you can prove the state wiring before any network exists.

Create shared/src/commonMain/kotlin/compose/project/demo/Book.kt:

Kotlin
package compose.project.demo

data class Book(
    val id: String,
    val title: String,
    val authors: String,
    val year: Int?,
)

Create ListViewModel.kt in the same folder. books uses an explicit backing field: the public type is the read-only StateFlow, while inside the class you can write to the MutableStateFlow behind it. The Compose Multiplatform ViewModel page says explicit backing fields were stabilized in Kotlin 2.4.0, so no compiler flag is needed.

Kotlin
package compose.project.demo

import androidx.lifecycle.ViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update

val sampleBooks = listOf(
    Book("sample-1", "Sample book one", "Author One", 2001),
    Book("sample-2", "Sample book two", "Author Two", 2012),
    Book("sample-3", "Sample book three", "Author Three", null),
)

class ListViewModel : ViewModel() {
    val books: StateFlow<List<Book>>
        field = MutableStateFlow(sampleBooks)

    fun reverse() {
        books.update { it.reversed() }
    }
}

Create Screens.kt in the same folder. It's tutorial UI code, not vendor sample code:

Kotlin
package compose.project.demo

import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeContentPadding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp

@Composable
fun BookListScreen(books: List<Book>, onReverse: () -> Unit) {
    Column(Modifier.fillMaxSize().safeContentPadding().padding(16.dp)) {
        Text("Reading List", style = MaterialTheme.typography.headlineMedium)
        Button(onClick = onReverse) { Text("Reverse order") }
        LazyColumn {
            items(books, key = { it.id }) { book ->
                Column(Modifier.padding(vertical = 8.dp)) {
                    Text(book.title, style = MaterialTheme.typography.titleMedium)
                    Text(book.authors)
                }
            }
        }
    }
}

Replace App.kt. On non-JVM targets you can't call viewModel() in common code without arguments, and the docs say to provide at least an initializer, which is the lambda here. collectAsStateWithLifecycle() turns the flow into Compose state. The desktop app needs kotlinx-coroutines-swing for viewModelScope, and the template's desktopApp already depends on it.

Kotlin
package compose.project.demo

import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel

@Composable
fun App() {
    MaterialTheme {
        val viewModel = viewModel { ListViewModel() }
        val books by viewModel.books.collectAsStateWithLifecycle()
        BookListScreen(books = books, onReverse = viewModel::reverse)
    }
}

Checkpoint: start ./gradlew :desktopApp:hotRun from the project root, then click "Reverse order".

The window shows a Reverse order button above three sample books, each with its author. Every click flips the order, so after one click "Sample book three" is at the top.

If Gradle fails with Unresolved reference on viewModel, check that step 1's libs.androidx.lifecycle.viewmodelCompose line is in shared/build.gradle.kts.

4. Add Navigation between list and detail

Add two routes, a detail screen and a NavHost so tapping a book opens its detail and Back returns. Routes are @Serializable types, which is how the Compose Multiplatform navigation docs type-check arguments.

One caveat on the version. The artifact is org.jetbrains.androidx.navigation:navigation-compose:2.10.0-beta01, a beta. It's still the right pin, because it's the version JetBrains lists with Compose Multiplatform 1.12.1 in the release notes from the intro.

Replace Screens.kt. The list rows are now clickable, and there's a detail screen. The book parameter is nullable because the detail screen can be asked for an id that isn't in memory:

Kotlin
package compose.project.demo

import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeContentPadding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp

@Composable
fun BookListScreen(books: List<Book>, onReverse: () -> Unit, onOpen: (Book) -> Unit) {
    Column(Modifier.fillMaxSize().safeContentPadding().padding(16.dp)) {
        Text("Reading List", style = MaterialTheme.typography.headlineMedium)
        Button(onClick = onReverse) { Text("Reverse order") }
        LazyColumn {
            items(books, key = { it.id }) { book ->
                Column(
                    Modifier
                        .fillMaxWidth()
                        .clickable { onOpen(book) }
                        .padding(vertical = 8.dp)
                ) {
                    Text(book.title, style = MaterialTheme.typography.titleMedium)
                    Text(book.authors)
                }
            }
        }
    }
}

@Composable
fun BookDetailScreen(book: Book?, onBack: () -> Unit) {
    Column(Modifier.fillMaxSize().safeContentPadding().padding(16.dp)) {
        Button(onClick = onBack) { Text("Back") }
        if (book == null) {
            Text("Book not found.")
        } else {
            Text(book.title, style = MaterialTheme.typography.headlineMedium)
            Text(book.authors)
            Text(book.year?.let { "First published $it" } ?: "Year unknown")
        }
    }
}

Replace App.kt. rememberNavController() holds the back stack, composable<ListRoute> registers a destination by type, and toRoute<DetailRoute>() reads the argument back out:

Kotlin
package compose.project.demo

import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import androidx.navigation.toRoute
import kotlinx.serialization.Serializable

@Serializable
data object ListRoute

@Serializable
data class DetailRoute(val id: String)

@Composable
fun App() {
    MaterialTheme {
        val navController = rememberNavController()
        NavHost(navController, startDestination = ListRoute) {
            composable<ListRoute> {
                val viewModel = viewModel { ListViewModel() }
                val books by viewModel.books.collectAsStateWithLifecycle()
                BookListScreen(
                    books = books,
                    onReverse = viewModel::reverse,
                    onOpen = { navController.navigate(DetailRoute(it.id)) },
                )
            }
            composable<DetailRoute> { entry ->
                val route = entry.toRoute<DetailRoute>()
                BookDetailScreen(
                    book = sampleBooks.firstOrNull { it.id == route.id },
                    onBack = { navController.popBackStack() },
                )
            }
        }
    }
}

Checkpoint: run ./gradlew :desktopApp:hotRun, click "Sample book two", then click Back.

Clicking "Sample book two" opens a detail screen with a Back button, the title, "Author Two" and "First published 2012". Back returns you to the list.

The list screen comes back with its order intact after Back, because its ViewModel stays alive on the back stack. If the build fails on serializer or Serializable, the Kotlin serialization plugin from step 1 isn't applied to shared.

5. Fetch books with Ktor and kotlinx.serialization

Replace the sample data with live search results from Open Library, and give the list screen three states: loading, data and error. Ktor's multiplatform client docs cover the engine-per-target setup you did in step 1, and the ContentNegotiation plugin does the JSON decoding.

Open Library's search endpoint takes q, limit and fields, and needs no key. Its API usage rules set a default of 1 request per second and 3 per second for requests that identify themselves with a User-Agent carrying an app name and a contact email. They also say to cache responses where you can, and that the APIs aren't meant as a bulk data backend. This app sends one request per button press, which fits. Put your own address in the User-Agent constant below. The fields list matters too: author_name and first_publish_year can be missing from a result, so the DTO gives them defaults.

Create BookApi.kt. A search result's key looks like /works/OL25550347W. The code strips the /works/ prefix to keep the id short and URL-safe:

Kotlin
package compose.project.demo

import io.ktor.client.HttpClient
import io.ktor.client.call.body
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.client.request.get
import io.ktor.client.request.header
import io.ktor.client.request.parameter
import io.ktor.http.HttpHeaders
import io.ktor.serialization.kotlinx.json.json
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json

private const val USER_AGENT = "ReadingList/1.0 (you@example.com)"

@Serializable
data class SearchResponse(val docs: List<BookDto> = emptyList())

@Serializable
data class BookDto(
    val key: String,
    val title: String,
    @SerialName("author_name") val authors: List<String> = emptyList(),
    @SerialName("first_publish_year") val year: Int? = null,
) {
    fun toBook() = Book(
        id = key.removePrefix("/works/"),
        title = title,
        authors = authors.joinToString().ifBlank { "Unknown author" },
        year = year,
    )
}

class BookApi(private val client: HttpClient) {
    suspend fun search(query: String): List<Book> =
        client.get("https://openlibrary.org/search.json") {
            header(HttpHeaders.UserAgent, USER_AGENT)
            parameter("q", query)
            parameter("limit", 20)
            parameter("fields", "key,title,author_name,first_publish_year")
        }.body<SearchResponse>().docs.map { it.toBook() }
}

fun buildClient() = HttpClient {
    install(ContentNegotiation) {
        json(Json { ignoreUnknownKeys = true })
    }
}

Create BookRepository.kt. It keeps the latest results in memory so the detail screen can find a book by id:

Kotlin
package compose.project.demo

import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update

class BookRepository(private val api: BookApi) {
    val results: StateFlow<List<Book>>
        field = MutableStateFlow(emptyList())

    suspend fun search(query: String): List<Book> {
        val books = api.search(query)
        results.update { books }
        return books
    }
}

Replace ListViewModel.kt. ListState is the screen's state. The catch for CancellationException rethrows it, so cancelling a search (for example when the screen goes away) isn't reported as an error:

Kotlin
package compose.project.demo

import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch

sealed interface ListState {
    data object Idle : ListState
    data object Loading : ListState
    data class Data(val books: List<Book>) : ListState
    data class Error(val message: String) : ListState
}

class ListViewModel(private val repository: BookRepository) : ViewModel() {
    val state: StateFlow<ListState>
        field = MutableStateFlow<ListState>(ListState.Idle)

    fun search(query: String) {
        if (query.isBlank()) return
        state.value = ListState.Loading
        viewModelScope.launch {
            state.value = try {
                ListState.Data(repository.search(query.trim()))
            } catch (e: CancellationException) {
                throw e
            } catch (e: Exception) {
                ListState.Error(e.message ?: e::class.simpleName ?: "unknown error")
            }
        }
    }
}

Create DetailViewModel.kt:

Kotlin
package compose.project.demo

import androidx.lifecycle.ViewModel

class DetailViewModel(private val repository: BookRepository) : ViewModel() {
    fun book(id: String): Book? = repository.results.value.firstOrNull { it.id == id }
}

Replace Screens.kt with a search box and the three states:

Kotlin
package compose.project.demo

import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeContentPadding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp

@Composable
fun BookListScreen(
    state: ListState,
    onSearch: (String) -> Unit,
    onOpen: (Book) -> Unit,
) {
    var query by rememberSaveable { mutableStateOf("") }
    Column(Modifier.fillMaxSize().safeContentPadding().padding(16.dp)) {
        Text("Reading List", style = MaterialTheme.typography.headlineMedium)
        Row(
            horizontalArrangement = Arrangement.spacedBy(8.dp),
            verticalAlignment = Alignment.CenterVertically,
        ) {
            OutlinedTextField(
                value = query,
                onValueChange = { query = it },
                label = { Text("Search books") },
                singleLine = true,
                modifier = Modifier.weight(1f),
            )
            Button(onClick = { onSearch(query) }) { Text("Search") }
        }
        when (state) {
            ListState.Idle -> Text("Search for a title or an author.")
            ListState.Loading -> CircularProgressIndicator()
            is ListState.Error -> Text("Could not load books: ${state.message}")
            is ListState.Data -> LazyColumn {
                items(state.books, key = { it.id }) { book ->
                    Column(
                        Modifier
                            .fillMaxWidth()
                            .clickable { onOpen(book) }
                            .padding(vertical = 8.dp)
                    ) {
                        Text(book.title, style = MaterialTheme.typography.titleMedium)
                        Text(book.authors)
                    }
                }
            }
        }
    }
}

@Composable
fun BookDetailScreen(book: Book?, onBack: () -> Unit) {
    Column(Modifier.fillMaxSize().safeContentPadding().padding(16.dp)) {
        Button(onClick = onBack) { Text("Back") }
        if (book == null) {
            Text("Book not found.")
        } else {
            Text(book.title, style = MaterialTheme.typography.headlineMedium)
            Text(book.authors)
            Text(book.year?.let { "First published $it" } ?: "Year unknown")
        }
    }
}

Replace App.kt. The repository is built by hand here, and step 6 removes that:

Kotlin
package compose.project.demo

import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import androidx.navigation.toRoute
import kotlinx.serialization.Serializable

@Serializable
data object ListRoute

@Serializable
data class DetailRoute(val id: String)

@Composable
fun App() {
    MaterialTheme {
        val repository = remember { BookRepository(BookApi(buildClient())) }
        val navController = rememberNavController()
        NavHost(navController, startDestination = ListRoute) {
            composable<ListRoute> {
                val viewModel = viewModel { ListViewModel(repository) }
                val state by viewModel.state.collectAsStateWithLifecycle()
                BookListScreen(
                    state = state,
                    onSearch = viewModel::search,
                    onOpen = { navController.navigate(DetailRoute(it.id)) },
                )
            }
            composable<DetailRoute> { entry ->
                val route = entry.toRoute<DetailRoute>()
                val viewModel = viewModel { DetailViewModel(repository) }
                BookDetailScreen(
                    book = viewModel.book(route.id),
                    onBack = { navController.popBackStack() },
                )
            }
        }
    }
}

Checkpoint: run ./gradlew :desktopApp:hotRun, type kotlin and press Search.

The list fills with 20 books from Open Library, each with its authors. When we ran it, the first rows were Kotlin Apprentice, Kotlin Programming, Kotlin and Kotlin in Action. The order changes as Open Library's index changes.

The error path is built in, so test it now. Turn Wi-Fi off, press Search again, and you should see Could not load books: followed by a platform-specific message. Turn Wi-Fi back on afterwards. If every search shows an error even online, check the USER_AGENT constant, since Open Library asks for an identifying header.

6. Wire dependency injection with Koin

Move all construction into a Koin module so no UI code creates a repository or ViewModel by hand. This step uses Koin's classic module { } DSL. Koin's own Compose Multiplatform quickstart uses the Compiler Plugin DSL, which needs Kotlin 2.3.20 or later and an extra Gradle plugin, and we'd rather not add a plugin to a tutorial that already has three.

Create AppModule.kt. Per Koin's Compose docs, singleOf(::X) and viewModelOf(::X) resolve constructor arguments from the container. Don't declare a ViewModel as single, because it'd outlive its screen:

Kotlin
package compose.project.demo

import org.koin.core.module.dsl.singleOf
import org.koin.core.module.dsl.viewModelOf
import org.koin.dsl.module

val appModule = module {
    single { buildClient() }
    singleOf(::BookApi)
    singleOf(::BookRepository)
    viewModelOf(::ListViewModel)
    viewModelOf(::DetailViewModel)
}

Replace App.kt. KoinApplication starts Koin inside the composition. On Android it calls androidContext() for you, and on other platforms it sets up a print logger, per Koin's setup page. koinViewModel() replaces the hand-built viewModel { ... } calls:

Kotlin
package compose.project.demo

import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import androidx.navigation.toRoute
import kotlinx.serialization.Serializable
import org.koin.compose.KoinApplication
import org.koin.compose.viewmodel.koinViewModel
import org.koin.dsl.koinConfiguration

@Serializable
data object ListRoute

@Serializable
data class DetailRoute(val id: String)

@Composable
fun App() {
    KoinApplication(configuration = koinConfiguration { modules(appModule) }) {
        MaterialTheme {
            val navController = rememberNavController()
            NavHost(navController, startDestination = ListRoute) {
                composable<ListRoute> {
                    val viewModel = koinViewModel<ListViewModel>()
                    val state by viewModel.state.collectAsStateWithLifecycle()
                    BookListScreen(
                        state = state,
                        onSearch = viewModel::search,
                        onOpen = { navController.navigate(DetailRoute(it.id)) },
                    )
                }
                composable<DetailRoute> { entry ->
                    val route = entry.toRoute<DetailRoute>()
                    val viewModel = koinViewModel<DetailViewModel>()
                    BookDetailScreen(
                        book = viewModel.book(route.id),
                        onBack = { navController.popBackStack() },
                    )
                }
            }
        }
    }
}

Checkpoint: run ./gradlew :desktopApp:hotRun, search for kotlin and open a result, then go back.

The app behaves exactly as it did at the end of step 5: a search for kotlin lists 20 books, and clicking one opens its detail screen. Nothing on screen changes, because Koin only changed how the objects are built.

Nothing visible has changed, which is the point: App.kt no longer mentions BookApi or buildClient(). If the app starts and then crashes with a Koin error saying a definition is missing, a class in AppModule.kt isn't registered.

7. Save favourites with Room

Add a Room database that stores favourites, a repository that merges the API with it, and a favourite button on both screens. Room needs the one expect/actual pair you write yourself, because each platform decides where the database file lives.

The Room KMP page is the source for the shapes here, and two things differ from it. Its commonMain example calls setQueryCoroutineContext(Dispatchers.IO), which in our build fails the iOS compile with Cannot access 'val IO': it is internal in 'kotlinx.coroutines.Dispatchers'. Leave the call out. The same page says that without a context Room defaults to Dispatchers.IO. And its sample catalog shows 3.1.0-alpha01, while the Room 3 release page lists 3.0.3 as stable, which is what step 1 pinned.

Create Database.kt. The entity column is workId, not key, because key is a SQL keyword. The DAO functions are suspend or return a Flow, which Room requires outside Android. AppDatabaseConstructor is declared expect and Room's KSP processor generates the matching actual on every target. BundledSQLiteDriver ships its own SQLite, so all three platforms behave the same:

Kotlin
package compose.project.demo

import androidx.room3.ConstructedBy
import androidx.room3.Dao
import androidx.room3.Database
import androidx.room3.Entity
import androidx.room3.PrimaryKey
import androidx.room3.Query
import androidx.room3.RoomDatabase
import androidx.room3.RoomDatabaseConstructor
import androidx.room3.Upsert
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
import kotlinx.coroutines.flow.Flow

@Entity
data class FavouriteEntity(@PrimaryKey val workId: String, val title: String)

@Dao
interface FavouriteDao {
    @Upsert
    suspend fun upsert(item: FavouriteEntity)

    @Query("DELETE FROM FavouriteEntity WHERE workId = :workId")
    suspend fun delete(workId: String)

    @Query("SELECT * FROM FavouriteEntity")
    fun all(): Flow<List<FavouriteEntity>>
}

@Database(entities = [FavouriteEntity::class], version = 1)
@ConstructedBy(AppDatabaseConstructor::class)
abstract class AppDatabase : RoomDatabase() {
    abstract fun favouriteDao(): FavouriteDao
}

@Suppress("KotlinNoActualForExpect")
expect object AppDatabaseConstructor : RoomDatabaseConstructor<AppDatabase> {
    override fun initialize(): AppDatabase
}

fun buildDatabase(builder: RoomDatabase.Builder<AppDatabase>): AppDatabase =
    builder.setDriver(BundledSQLiteDriver()).build()

Create PlatformModule.kt. This is the expect side: each platform returns a Koin module that provides a RoomDatabase.Builder<AppDatabase>. Under Kotlin's expect/actual rules, the declaration in common code needs an actual in the same package on every target:

Kotlin
package compose.project.demo

import org.koin.core.module.Module

expect fun platformModule(): Module

Create shared/src/androidMain/kotlin/compose/project/demo/PlatformModule.android.kt. The Room docs build the Android path with getDatabasePath. Koin registers the Android context for you, so get<Context>() retrieves it:

Kotlin
package compose.project.demo

import android.content.Context
import androidx.room3.Room
import androidx.room3.RoomDatabase
import org.koin.core.module.Module
import org.koin.dsl.module

actual fun platformModule(): Module = module {
    single<RoomDatabase.Builder<AppDatabase>> {
        val context = get<Context>()
        Room.databaseBuilder<AppDatabase>(
            context = context,
            name = context.getDatabasePath("readinglist.db").absolutePath,
        )
    }
}

Create shared/src/iosMain/kotlin/compose/project/demo/PlatformModule.ios.kt. On iOS the file goes in the app's documents directory:

Kotlin
package compose.project.demo

import androidx.room3.Room
import androidx.room3.RoomDatabase
import kotlinx.cinterop.ExperimentalForeignApi
import org.koin.core.module.Module
import org.koin.dsl.module
import platform.Foundation.NSDocumentDirectory
import platform.Foundation.NSFileManager
import platform.Foundation.NSUserDomainMask

@OptIn(ExperimentalForeignApi::class)
actual fun platformModule(): Module = module {
    single<RoomDatabase.Builder<AppDatabase>> {
        val documentDirectory = NSFileManager.defaultManager.URLForDirectory(
            directory = NSDocumentDirectory,
            inDomain = NSUserDomainMask,
            appropriateForURL = null,
            create = false,
            error = null,
        )
        Room.databaseBuilder<AppDatabase>(
            name = requireNotNull(documentDirectory?.path) + "/readinglist.db",
        )
    }
}

Create shared/src/jvmMain/kotlin/compose/project/demo/PlatformModule.jvm.kt. The Room docs put the desktop file under java.io.tmpdir and warn that the temp directory might be cleaned on reboot. That would make favourites vanish, so this tutorial code uses a folder under your home directory:

Kotlin
package compose.project.demo

import androidx.room3.Room
import androidx.room3.RoomDatabase
import java.io.File
import org.koin.core.module.Module
import org.koin.dsl.module

actual fun platformModule(): Module = module {
    single<RoomDatabase.Builder<AppDatabase>> {
        val directory = File(System.getProperty("user.home"), ".readinglist")
        directory.mkdirs()
        Room.databaseBuilder<AppDatabase>(
            name = File(directory, "readinglist.db").absolutePath,
        )
    }
}

Replace BookRepository.kt so it combines the API and the DAO:

Kotlin
package compose.project.demo

import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.update

class BookRepository(
    private val api: BookApi,
    private val dao: FavouriteDao,
) {
    val results: StateFlow<List<Book>>
        field = MutableStateFlow(emptyList())

    val favouriteIds: Flow<Set<String>> =
        dao.all().map { items -> items.map { it.workId }.toSet() }

    suspend fun search(query: String): List<Book> {
        val books = api.search(query)
        results.update { books }
        return books
    }

    suspend fun setFavourite(book: Book, favourite: Boolean) {
        if (favourite) {
            dao.upsert(FavouriteEntity(book.id, book.title))
        } else {
            dao.delete(book.id)
        }
    }
}

Replace ListViewModel.kt. It now also exposes the set of favourite ids and a toggle:

Kotlin
package compose.project.demo

import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch

sealed interface ListState {
    data object Idle : ListState
    data object Loading : ListState
    data class Data(val books: List<Book>) : ListState
    data class Error(val message: String) : ListState
}

class ListViewModel(private val repository: BookRepository) : ViewModel() {
    val state: StateFlow<ListState>
        field = MutableStateFlow<ListState>(ListState.Idle)

    val favouriteIds: StateFlow<Set<String>> = repository.favouriteIds
        .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptySet())

    fun search(query: String) {
        if (query.isBlank()) return
        state.value = ListState.Loading
        viewModelScope.launch {
            state.value = try {
                ListState.Data(repository.search(query.trim()))
            } catch (e: CancellationException) {
                throw e
            } catch (e: Exception) {
                ListState.Error(e.message ?: e::class.simpleName ?: "unknown error")
            }
        }
    }

    fun toggleFavourite(book: Book) {
        viewModelScope.launch {
            repository.setFavourite(book, book.id !in favouriteIds.value)
        }
    }
}

Replace DetailViewModel.kt:

Kotlin
package compose.project.demo

import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch

class DetailViewModel(private val repository: BookRepository) : ViewModel() {
    val favouriteIds: StateFlow<Set<String>> = repository.favouriteIds
        .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptySet())

    fun book(id: String): Book? = repository.results.value.firstOrNull { it.id == id }

    fun toggleFavourite(book: Book) {
        viewModelScope.launch {
            repository.setFavourite(book, book.id !in favouriteIds.value)
        }
    }
}

Replace Screens.kt with the version that shows the favourite count and a button on each row and on the detail screen:

Kotlin
package compose.project.demo

import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeContentPadding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp

@Composable
fun BookListScreen(
    state: ListState,
    favouriteIds: Set<String>,
    onSearch: (String) -> Unit,
    onOpen: (Book) -> Unit,
    onToggleFavourite: (Book) -> Unit,
) {
    var query by rememberSaveable { mutableStateOf("") }
    Column(Modifier.fillMaxSize().safeContentPadding().padding(16.dp)) {
        Text("Reading List", style = MaterialTheme.typography.headlineMedium)
        Text("Favourites: ${favouriteIds.size}")
        Row(
            horizontalArrangement = Arrangement.spacedBy(8.dp),
            verticalAlignment = Alignment.CenterVertically,
        ) {
            OutlinedTextField(
                value = query,
                onValueChange = { query = it },
                label = { Text("Search books") },
                singleLine = true,
                modifier = Modifier.weight(1f),
            )
            Button(onClick = { onSearch(query) }) { Text("Search") }
        }
        when (state) {
            ListState.Idle -> Text("Search for a title or an author.")
            ListState.Loading -> CircularProgressIndicator()
            is ListState.Error -> Text("Could not load books: ${state.message}")
            is ListState.Data -> LazyColumn {
                items(state.books, key = { it.id }) { book ->
                    Row(
                        Modifier
                            .fillMaxWidth()
                            .clickable { onOpen(book) }
                            .padding(vertical = 8.dp),
                        verticalAlignment = Alignment.CenterVertically,
                    ) {
                        Column(Modifier.weight(1f)) {
                            Text(book.title, style = MaterialTheme.typography.titleMedium)
                            Text(book.authors)
                        }
                        TextButton(onClick = { onToggleFavourite(book) }) {
                            Text(if (book.id in favouriteIds) "Remove" else "Favourite")
                        }
                    }
                }
            }
        }
    }
}

@Composable
fun BookDetailScreen(
    book: Book?,
    isFavourite: Boolean,
    onBack: () -> Unit,
    onToggleFavourite: (Book) -> Unit,
) {
    Column(Modifier.fillMaxSize().safeContentPadding().padding(16.dp)) {
        Button(onClick = onBack) { Text("Back") }
        if (book == null) {
            Text("Book not found.")
        } else {
            Text(book.title, style = MaterialTheme.typography.headlineMedium)
            Text(book.authors)
            Text(book.year?.let { "First published $it" } ?: "Year unknown")
            Button(onClick = { onToggleFavourite(book) }) {
                Text(if (isFavourite) "Remove from favourites" else "Add to favourites")
            }
        }
    }
}

Replace AppModule.kt. The two new lines build the database from the platform's builder and expose the DAO:

Kotlin
package compose.project.demo

import org.koin.core.module.dsl.singleOf
import org.koin.core.module.dsl.viewModelOf
import org.koin.dsl.module

val appModule = module {
    single { buildClient() }
    single { buildDatabase(get()) }
    single { get<AppDatabase>().favouriteDao() }
    singleOf(::BookApi)
    singleOf(::BookRepository)
    viewModelOf(::ListViewModel)
    viewModelOf(::DetailViewModel)
}

Replace App.kt. It adds platformModule() to Koin and passes the favourite state down:

Kotlin
package compose.project.demo

import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import androidx.navigation.toRoute
import kotlinx.serialization.Serializable
import org.koin.compose.KoinApplication
import org.koin.compose.viewmodel.koinViewModel
import org.koin.dsl.koinConfiguration

@Serializable
data object ListRoute

@Serializable
data class DetailRoute(val id: String)

@Composable
fun App() {
    KoinApplication(
        configuration = koinConfiguration { modules(appModule, platformModule()) }
    ) {
        MaterialTheme {
            val navController = rememberNavController()
            NavHost(navController, startDestination = ListRoute) {
                composable<ListRoute> {
                    val viewModel = koinViewModel<ListViewModel>()
                    val state by viewModel.state.collectAsStateWithLifecycle()
                    val favouriteIds by viewModel.favouriteIds.collectAsStateWithLifecycle()
                    BookListScreen(
                        state = state,
                        favouriteIds = favouriteIds,
                        onSearch = viewModel::search,
                        onOpen = { navController.navigate(DetailRoute(it.id)) },
                        onToggleFavourite = viewModel::toggleFavourite,
                    )
                }
                composable<DetailRoute> { entry ->
                    val route = entry.toRoute<DetailRoute>()
                    val viewModel = koinViewModel<DetailViewModel>()
                    val favouriteIds by viewModel.favouriteIds.collectAsStateWithLifecycle()
                    BookDetailScreen(
                        book = viewModel.book(route.id),
                        isFavourite = route.id in favouriteIds,
                        onBack = { navController.popBackStack() },
                        onToggleFavourite = viewModel::toggleFavourite,
                    )
                }
            }
        }
    }
}

Checkpoint: run ./gradlew :desktopApp:hotRun, search for kotlin, click Favourite on the first book, then close the window. Start ./gradlew :desktopApp:hotRun again and search for kotlin once more.

Each build in this step prints a warning that expect/actual classes are in Beta, triggered by AppDatabaseConstructor. It's a warning, not an error, and the build still succeeds.

After the restart, the counter still reads "Favourites: 1", and when you search kotlin again, the book you saved shows a Remove button instead of Favourite.

If the build stops in a KSP task, re-read the four add("ksp...") lines from step 1, because a missing target there is the usual cause.

8. Run the iOS app in the simulator

The iOS app needs nothing from you. The template already contains MainViewController() in shared/src/iosMain and iosApp/iosApp/ContentView.swift, which wraps it. That file, shown here only so you know what Xcode will build, should look like:

Swift
import UIKit
import SwiftUI
import Shared

struct ComposeView: UIViewControllerRepresentable {
    func makeUIViewController(context: Self.Context) -> UIViewController {
        MainViewControllerKt.MainViewController()
    }

    func updateUIViewController(_ uiViewController: UIViewController, context: Self.Context) {}
}

struct ContentView: View {
    var body: some View {
        ComposeView()
            .ignoresSafeArea()
    }
}

First prove the Kotlin side builds, from the project root. The framework is named Shared and is static, which your shared/build.gradle.kts from step 1 sets:

Bash
./gradlew :shared:linkDebugFrameworkIosSimulatorArm64

You should see something like (the time will differ):

text
BUILD SUCCESSFUL in 2m 8s

Then open iosApp/iosApp.xcodeproj in Xcode, pick an iPhone simulator and press Run. The first build is slow, because an Xcode build phase runs ./gradlew :shared:embedAndSignAppleFrameworkForXcode and compiles the whole Kotlin framework.

Xcode reports BUILD SUCCEEDED and the simulator launches the app, which the template names ComposeDemo. It opens on the list screen: "Reading List", "Favourites: 0", an empty search box with a Search button, and the hint "Search for a title or an author."

9. Run and verify on every target

Run the whole app end to end. The files you built in steps 1 to 7 now match the Full code section below, apart from comments. Results come from Open Library's live index, so the books you see can differ from the ones below.

Start with desktop, from the project root:

Bash
./gradlew :desktopApp:run

Search for kotlin. The list holds 20 books. Open the first one, click "Add to favourites", go Back, and close the window. Then run the command again and search kotlin once more. The first book is still marked "Remove", which shows Room wrote to disk.

After the restart the counter reads "Favourites: 1", and the first book in the new search shows Remove.

Confirm the database file exists:

Bash
test -f ~/.readinglist/readinglist.db && echo found

You should see something like:

text
found

Search for a string that matches nothing, such as zzqqxxnotabook123. The list comes back empty and the app doesn't crash.

Next, Android. Build the debug APK from the project root:

Bash
./gradlew :androidApp:assembleDebug

You should see something like (the time will differ):

text
BUILD SUCCESSFUL in 1m 4s

Run the app on an Android emulator with the IDE's androidApp run configuration, search for kotlin, open the first result and click "Add to favourites". When we ran it on an API 37 emulator, the detail screen then showed Kotlin Apprentice, its authors, "First published 2018", and a button that now reads "Remove from favourites".

Now force-stop the app and launch it again.

The list screen reopens with "Favourites: 1", so the favourite survived the restart.

The iOS simulator from step 8 runs the same shared code, and each search is one request to Open Library.

Negative check: put the Android emulator in airplane mode and press Search. The app should show an error line, not crash.

You should see something like (on Android; other platforms word the message after the colon differently):

text
Could not load books: Unable to resolve host "openlibrary.org": No address associated with hostname

Turn airplane mode off and the next search works without restarting.

Architecture

text
Open Library (search.json)
        |
        v
BookApi (Ktor client, kotlinx.serialization)
        |
        v
BookRepository <---- FavouriteDao (Room, bundled SQLite)
        ^                       ^
        |                       |
ListViewModel / DetailViewModel |
        |                       |
        v                       |
Compose screens (NavHost) -------
        |
  Koin wires everything; platformModule() supplies the database file
  path on Android, iOS and desktop

Clean up

Stop the desktop app by closing its window, or press Ctrl+C in the terminal running Gradle. Stop the Gradle daemons:

Bash
./gradlew --stop

Uninstall the app from the Android emulator (press and hold the icon, then uninstall it) and from the iOS simulator, where it appears as ComposeDemo (press and hold the icon, then remove it). The desktop database lives in ~/.readinglist. Delete it:

Bash
rm -rf ~/.readinglist

If you no longer need the project, delete the folder too: rm -rf ReadingList from its parent directory.

Full code

The steps built every file below. This project needs no API key, so there's no .env file and nothing secret in the source. The only personal value is the contact email in the User-Agent constant in BookApi.kt.

Project layout and commands

Template files you didn't touch (Platform.kt and its actual files, composeResources, MainActivity.kt, MainViewController.kt, desktopApp/build.gradle.kts, androidApp/build.gradle.kts, the iosApp folder and gradle.properties) are left out. settings.gradle.kts is the template's with the include(":webApp") line deleted and rootProject.name = "ReadingList" set, as in step 1.

text
ReadingList/
├── gradle/
│   └── libs.versions.toml
├── build.gradle.kts
├── settings.gradle.kts
├── androidApp/
│   └── src/main/AndroidManifest.xml
├── desktopApp/
│   └── src/main/kotlin/compose/project/demo/
│       └── main.kt
└── shared/
    ├── build.gradle.kts
    └── src/
        ├── commonMain/kotlin/compose/project/demo/
        │   ├── App.kt
        │   ├── AppModule.kt
        │   ├── Book.kt
        │   ├── BookApi.kt
        │   ├── BookRepository.kt
        │   ├── Database.kt
        │   ├── DetailViewModel.kt
        │   ├── ListViewModel.kt
        │   ├── PlatformModule.kt
        │   └── Screens.kt
        ├── androidMain/kotlin/compose/project/demo/
        │   └── PlatformModule.android.kt
        ├── iosMain/kotlin/compose/project/demo/
        │   └── PlatformModule.ios.kt
        └── jvmMain/kotlin/compose/project/demo/
            └── PlatformModule.jvm.kt

Clone the template as in step 1 (commit ca0b6d2), remove the web app, tests and Greeting files, apply the settings.gradle.kts change, add the files below, then run, from the project root:

Bash
./gradlew :shared:compileKotlinJvm
./gradlew :desktopApp:run
./gradlew :androidApp:assembleDebug
./gradlew :shared:linkDebugFrameworkIosSimulatorArm64

For iOS, open iosApp/iosApp.xcodeproj in Xcode and run on a simulator.

Gradle files

gradle/libs.versions.toml:

toml
[versions]
agp = "9.1.1"
android-compileSdk = "37"
android-minSdk = "24"
android-targetSdk = "37"
androidx-activity = "1.13.0"
androidx-appcompat = "1.8.0"
androidx-core = "1.19.0"
androidx-espresso = "3.7.0"
androidx-lifecycle = "2.11.0"
androidx-testExt = "1.3.0"
composeMultiplatform = "1.12.1"
junit = "4.13.2"
kotlin = "2.4.20"
kotlinx-coroutines = "1.11.0"
material3 = "1.12.0-alpha03"
ksp = "2.3.12"
room3 = "3.0.3"
androidx-sqlite = "2.7.1"
ktor = "3.6.0"
navigation = "2.10.0-beta01"
koin = "4.2.2"

[libraries]
kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" }
kotlin-testJunit = { module = "org.jetbrains.kotlin:kotlin-test-junit", version.ref = "kotlin" }
junit = { module = "junit:junit", version.ref = "junit" }
androidx-core-ktx = { module = "androidx.core:core-ktx", version.ref = "androidx-core" }
androidx-testExt-junit = { module = "androidx.test.ext:junit", version.ref = "androidx-testExt" }
androidx-espresso-core = { module = "androidx.test.espresso:espresso-core", version.ref = "androidx-espresso" }
androidx-appcompat = { module = "androidx.appcompat:appcompat", version.ref = "androidx-appcompat" }
androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidx-activity" }
compose-uiTooling = { module = "org.jetbrains.compose.ui:ui-tooling", version.ref = "composeMultiplatform" }
androidx-lifecycle-viewmodelCompose = { module = "org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "androidx-lifecycle" }
androidx-lifecycle-runtimeCompose = { module = "org.jetbrains.androidx.lifecycle:lifecycle-runtime-compose", version.ref = "androidx-lifecycle" }
compose-runtime = { module = "org.jetbrains.compose.runtime:runtime", version.ref = "composeMultiplatform" }
compose-foundation = { module = "org.jetbrains.compose.foundation:foundation", version.ref = "composeMultiplatform" }
compose-material3 = { module = "org.jetbrains.compose.material3:material3", version.ref = "material3" }
compose-ui = { module = "org.jetbrains.compose.ui:ui", version.ref = "composeMultiplatform" }
compose-components-resources = { module = "org.jetbrains.compose.components:components-resources", version.ref = "composeMultiplatform" }
compose-uiToolingPreview = { module = "org.jetbrains.compose.ui:ui-tooling-preview", version.ref = "composeMultiplatform" }
kotlinx-coroutinesSwing = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-swing", version.ref = "kotlinx-coroutines" }
room3-runtime = { module = "androidx.room3:room3-runtime", version.ref = "room3" }
room3-compiler = { module = "androidx.room3:room3-compiler", version.ref = "room3" }
androidx-sqlite-bundled = { module = "androidx.sqlite:sqlite-bundled", version.ref = "androidx-sqlite" }
ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" }
ktor-client-okhttp = { module = "io.ktor:ktor-client-okhttp", version.ref = "ktor" }
ktor-client-darwin = { module = "io.ktor:ktor-client-darwin", version.ref = "ktor" }
ktor-client-contentNegotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktor" }
ktor-serialization-kotlinxJson = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" }
navigation-compose = { module = "org.jetbrains.androidx.navigation:navigation-compose", version.ref = "navigation" }
koin-bom = { module = "io.insert-koin:koin-bom", version.ref = "koin" }
koin-core = { module = "io.insert-koin:koin-core" }
koin-compose = { module = "io.insert-koin:koin-compose" }
koin-compose-viewmodel = { module = "io.insert-koin:koin-compose-viewmodel" }

[plugins]
androidApplication = { id = "com.android.application", version.ref = "agp" }
androidMultiplatformLibrary = { id = "com.android.kotlin.multiplatform.library", version.ref = "agp" }
composeMultiplatform = { id = "org.jetbrains.compose", version.ref = "composeMultiplatform" }
composeCompiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
kotlinJvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" }
kotlinMultiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" }
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
room3 = { id = "androidx.room3", version.ref = "room3" }
kotlinSerialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }

build.gradle.kts:

Kotlin
plugins {
    alias(libs.plugins.androidApplication) apply false
    alias(libs.plugins.androidMultiplatformLibrary) apply false
    alias(libs.plugins.composeMultiplatform) apply false
    alias(libs.plugins.composeCompiler) apply false
    alias(libs.plugins.kotlinJvm) apply false
    alias(libs.plugins.kotlinMultiplatform) apply false
    alias(libs.plugins.ksp) apply false
    alias(libs.plugins.room3) apply false
    alias(libs.plugins.kotlinSerialization) apply false
}

shared/build.gradle.kts:

Kotlin
import org.jetbrains.kotlin.gradle.dsl.JvmTarget

plugins {
    alias(libs.plugins.kotlinMultiplatform)
    alias(libs.plugins.androidMultiplatformLibrary)
    alias(libs.plugins.composeMultiplatform)
    alias(libs.plugins.composeCompiler)
    alias(libs.plugins.ksp)
    alias(libs.plugins.room3)
    alias(libs.plugins.kotlinSerialization)
}

kotlin {
    listOf(
        iosArm64(),
        iosSimulatorArm64()
    ).forEach { iosTarget ->
        iosTarget.binaries.framework {
            baseName = "Shared"
            isStatic = true
        }
    }

    jvm()

    android {
        namespace = "compose.project.demo.shared"
        compileSdk = libs.versions.android.compileSdk.get().toInt()
        minSdk = libs.versions.android.minSdk.get().toInt()

        compilerOptions {
            jvmTarget = JvmTarget.JVM_11
        }
        androidResources {
            enable = true
        }
    }

    sourceSets {
        androidMain.dependencies {
            implementation(libs.ktor.client.okhttp)
            implementation(libs.compose.uiToolingPreview)
            implementation(libs.compose.uiTooling)
        }
        commonMain.dependencies {
            implementation(libs.compose.runtime)
            implementation(libs.compose.foundation)
            implementation(libs.compose.material3)
            implementation(libs.compose.ui)
            implementation(libs.compose.components.resources)
            implementation(libs.compose.uiToolingPreview)
            implementation(libs.androidx.lifecycle.viewmodelCompose)
            implementation(libs.androidx.lifecycle.runtimeCompose)
            implementation(libs.room3.runtime)
            implementation(libs.androidx.sqlite.bundled)
            implementation(libs.ktor.client.core)
            implementation(libs.ktor.client.contentNegotiation)
            implementation(libs.ktor.serialization.kotlinxJson)
            implementation(libs.navigation.compose)
            implementation(project.dependencies.platform(libs.koin.bom))
            implementation(libs.koin.core)
            implementation(libs.koin.compose)
            implementation(libs.koin.compose.viewmodel)
        }
        jvmMain.dependencies {
            implementation(libs.ktor.client.okhttp)
        }
        iosMain.dependencies {
            implementation(libs.ktor.client.darwin)
        }
    }
}

room3 {
    schemaDirectory("$projectDir/schemas")
}

dependencies {
    androidRuntimeClasspath(libs.compose.uiTooling)
    add("kspAndroid", libs.room3.compiler)
    add("kspJvm", libs.room3.compiler)
    add("kspIosArm64", libs.room3.compiler)
    add("kspIosSimulatorArm64", libs.room3.compiler)
}

androidApp/src/main/AndroidManifest.xml (the template's file plus the permission line):

text
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">

    <uses-permission android:name="android.permission.INTERNET" />

    <application
        android:allowBackup="true"
        android:icon="@mipmap/ic_launcher"
        android:label="@string/app_name"
        android:roundIcon="@mipmap/ic_launcher_round"
        android:supportsRtl="true"
        android:theme="@android:style/Theme.Material.Light.NoActionBar">
        <activity
            android:exported="true"
            android:name=".MainActivity">
            <intent-filter>
                <action android:name="android.intent.action.MAIN" />

                <category android:name="android.intent.category.LAUNCHER" />
            </intent-filter>
        </activity>
    </application>

</manifest>

Desktop entry point

desktopApp/src/main/kotlin/compose/project/demo/main.kt:

Kotlin
package compose.project.demo

import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Window
import androidx.compose.ui.window.WindowPosition
import androidx.compose.ui.window.application
import androidx.compose.ui.window.rememberWindowState

fun main() = application {
    val state = rememberWindowState(
        size = DpSize(480.dp, 720.dp),
        position = WindowPosition(300.dp, 100.dp)
    )
    Window(
        title = "Reading List",
        onCloseRequest = ::exitApplication,
        state = state,
        alwaysOnTop = true
    ) {
        App()
    }
}

Shared code

All files below live in shared/src/commonMain/kotlin/compose/project/demo/.

Book.kt:

Kotlin
package compose.project.demo

data class Book(
    val id: String,
    val title: String,
    val authors: String,
    val year: Int?,
)

BookApi.kt:

Kotlin
package compose.project.demo

import io.ktor.client.HttpClient
import io.ktor.client.call.body
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.client.request.get
import io.ktor.client.request.header
import io.ktor.client.request.parameter
import io.ktor.http.HttpHeaders
import io.ktor.serialization.kotlinx.json.json
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json

private const val USER_AGENT = "ReadingList/1.0 (you@example.com)"

@Serializable
data class SearchResponse(val docs: List<BookDto> = emptyList())

@Serializable
data class BookDto(
    val key: String,
    val title: String,
    @SerialName("author_name") val authors: List<String> = emptyList(),
    @SerialName("first_publish_year") val year: Int? = null,
) {
    fun toBook() = Book(
        id = key.removePrefix("/works/"),
        title = title,
        authors = authors.joinToString().ifBlank { "Unknown author" },
        year = year,
    )
}

class BookApi(private val client: HttpClient) {
    suspend fun search(query: String): List<Book> =
        client.get("https://openlibrary.org/search.json") {
            header(HttpHeaders.UserAgent, USER_AGENT)
            parameter("q", query)
            parameter("limit", 20)
            parameter("fields", "key,title,author_name,first_publish_year")
        }.body<SearchResponse>().docs.map { it.toBook() }
}

fun buildClient() = HttpClient {
    install(ContentNegotiation) {
        json(Json { ignoreUnknownKeys = true })
    }
}

Database.kt:

Kotlin
package compose.project.demo

import androidx.room3.ConstructedBy
import androidx.room3.Dao
import androidx.room3.Database
import androidx.room3.Entity
import androidx.room3.PrimaryKey
import androidx.room3.Query
import androidx.room3.RoomDatabase
import androidx.room3.RoomDatabaseConstructor
import androidx.room3.Upsert
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
import kotlinx.coroutines.flow.Flow

@Entity
data class FavouriteEntity(@PrimaryKey val workId: String, val title: String)

@Dao
interface FavouriteDao {
    @Upsert
    suspend fun upsert(item: FavouriteEntity)

    @Query("DELETE FROM FavouriteEntity WHERE workId = :workId")
    suspend fun delete(workId: String)

    @Query("SELECT * FROM FavouriteEntity")
    fun all(): Flow<List<FavouriteEntity>>
}

@Database(entities = [FavouriteEntity::class], version = 1)
@ConstructedBy(AppDatabaseConstructor::class)
abstract class AppDatabase : RoomDatabase() {
    abstract fun favouriteDao(): FavouriteDao
}

@Suppress("KotlinNoActualForExpect")
expect object AppDatabaseConstructor : RoomDatabaseConstructor<AppDatabase> {
    override fun initialize(): AppDatabase
}

fun buildDatabase(builder: RoomDatabase.Builder<AppDatabase>): AppDatabase =
    builder.setDriver(BundledSQLiteDriver()).build()

BookRepository.kt:

Kotlin
package compose.project.demo

import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.update

class BookRepository(
    private val api: BookApi,
    private val dao: FavouriteDao,
) {
    val results: StateFlow<List<Book>>
        field = MutableStateFlow(emptyList())

    val favouriteIds: Flow<Set<String>> =
        dao.all().map { items -> items.map { it.workId }.toSet() }

    suspend fun search(query: String): List<Book> {
        val books = api.search(query)
        results.update { books }
        return books
    }

    suspend fun setFavourite(book: Book, favourite: Boolean) {
        if (favourite) {
            dao.upsert(FavouriteEntity(book.id, book.title))
        } else {
            dao.delete(book.id)
        }
    }
}

ListViewModel.kt:

Kotlin
package compose.project.demo

import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch

sealed interface ListState {
    data object Idle : ListState
    data object Loading : ListState
    data class Data(val books: List<Book>) : ListState
    data class Error(val message: String) : ListState
}

class ListViewModel(private val repository: BookRepository) : ViewModel() {
    val state: StateFlow<ListState>
        field = MutableStateFlow<ListState>(ListState.Idle)

    val favouriteIds: StateFlow<Set<String>> = repository.favouriteIds
        .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptySet())

    fun search(query: String) {
        if (query.isBlank()) return
        state.value = ListState.Loading
        viewModelScope.launch {
            state.value = try {
                ListState.Data(repository.search(query.trim()))
            } catch (e: CancellationException) {
                throw e
            } catch (e: Exception) {
                ListState.Error(e.message ?: e::class.simpleName ?: "unknown error")
            }
        }
    }

    fun toggleFavourite(book: Book) {
        viewModelScope.launch {
            repository.setFavourite(book, book.id !in favouriteIds.value)
        }
    }
}

DetailViewModel.kt:

Kotlin
package compose.project.demo

import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch

class DetailViewModel(private val repository: BookRepository) : ViewModel() {
    val favouriteIds: StateFlow<Set<String>> = repository.favouriteIds
        .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptySet())

    fun book(id: String): Book? = repository.results.value.firstOrNull { it.id == id }

    fun toggleFavourite(book: Book) {
        viewModelScope.launch {
            repository.setFavourite(book, book.id !in favouriteIds.value)
        }
    }
}

Screens.kt:

Kotlin
package compose.project.demo

import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeContentPadding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp

@Composable
fun BookListScreen(
    state: ListState,
    favouriteIds: Set<String>,
    onSearch: (String) -> Unit,
    onOpen: (Book) -> Unit,
    onToggleFavourite: (Book) -> Unit,
) {
    var query by rememberSaveable { mutableStateOf("") }
    Column(Modifier.fillMaxSize().safeContentPadding().padding(16.dp)) {
        Text("Reading List", style = MaterialTheme.typography.headlineMedium)
        Text("Favourites: ${favouriteIds.size}")
        Row(
            horizontalArrangement = Arrangement.spacedBy(8.dp),
            verticalAlignment = Alignment.CenterVertically,
        ) {
            OutlinedTextField(
                value = query,
                onValueChange = { query = it },
                label = { Text("Search books") },
                singleLine = true,
                modifier = Modifier.weight(1f),
            )
            Button(onClick = { onSearch(query) }) { Text("Search") }
        }
        when (state) {
            ListState.Idle -> Text("Search for a title or an author.")
            ListState.Loading -> CircularProgressIndicator()
            is ListState.Error -> Text("Could not load books: ${state.message}")
            is ListState.Data -> LazyColumn {
                items(state.books, key = { it.id }) { book ->
                    Row(
                        Modifier
                            .fillMaxWidth()
                            .clickable { onOpen(book) }
                            .padding(vertical = 8.dp),
                        verticalAlignment = Alignment.CenterVertically,
                    ) {
                        Column(Modifier.weight(1f)) {
                            Text(book.title, style = MaterialTheme.typography.titleMedium)
                            Text(book.authors)
                        }
                        TextButton(onClick = { onToggleFavourite(book) }) {
                            Text(if (book.id in favouriteIds) "Remove" else "Favourite")
                        }
                    }
                }
            }
        }
    }
}

@Composable
fun BookDetailScreen(
    book: Book?,
    isFavourite: Boolean,
    onBack: () -> Unit,
    onToggleFavourite: (Book) -> Unit,
) {
    Column(Modifier.fillMaxSize().safeContentPadding().padding(16.dp)) {
        Button(onClick = onBack) { Text("Back") }
        if (book == null) {
            Text("Book not found.")
        } else {
            Text(book.title, style = MaterialTheme.typography.headlineMedium)
            Text(book.authors)
            Text(book.year?.let { "First published $it" } ?: "Year unknown")
            Button(onClick = { onToggleFavourite(book) }) {
                Text(if (isFavourite) "Remove from favourites" else "Add to favourites")
            }
        }
    }
}

AppModule.kt:

Kotlin
package compose.project.demo

import org.koin.core.module.dsl.singleOf
import org.koin.core.module.dsl.viewModelOf
import org.koin.dsl.module

val appModule = module {
    single { buildClient() }
    single { buildDatabase(get()) }
    single { get<AppDatabase>().favouriteDao() }
    singleOf(::BookApi)
    singleOf(::BookRepository)
    viewModelOf(::ListViewModel)
    viewModelOf(::DetailViewModel)
}

PlatformModule.kt:

Kotlin
package compose.project.demo

import org.koin.core.module.Module

expect fun platformModule(): Module

App.kt:

Kotlin
package compose.project.demo

import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import androidx.navigation.toRoute
import kotlinx.serialization.Serializable
import org.koin.compose.KoinApplication
import org.koin.compose.viewmodel.koinViewModel
import org.koin.dsl.koinConfiguration

@Serializable
data object ListRoute

@Serializable
data class DetailRoute(val id: String)

@Composable
fun App() {
    KoinApplication(
        configuration = koinConfiguration { modules(appModule, platformModule()) }
    ) {
        MaterialTheme {
            val navController = rememberNavController()
            NavHost(navController, startDestination = ListRoute) {
                composable<ListRoute> {
                    val viewModel = koinViewModel<ListViewModel>()
                    val state by viewModel.state.collectAsStateWithLifecycle()
                    val favouriteIds by viewModel.favouriteIds.collectAsStateWithLifecycle()
                    BookListScreen(
                        state = state,
                        favouriteIds = favouriteIds,
                        onSearch = viewModel::search,
                        onOpen = { navController.navigate(DetailRoute(it.id)) },
                        onToggleFavourite = viewModel::toggleFavourite,
                    )
                }
                composable<DetailRoute> { entry ->
                    val route = entry.toRoute<DetailRoute>()
                    val viewModel = koinViewModel<DetailViewModel>()
                    val favouriteIds by viewModel.favouriteIds.collectAsStateWithLifecycle()
                    BookDetailScreen(
                        book = viewModel.book(route.id),
                        isFavourite = route.id in favouriteIds,
                        onBack = { navController.popBackStack() },
                        onToggleFavourite = viewModel::toggleFavourite,
                    )
                }
            }
        }
    }
}

Platform code

shared/src/androidMain/kotlin/compose/project/demo/PlatformModule.android.kt:

Kotlin
package compose.project.demo

import android.content.Context
import androidx.room3.Room
import androidx.room3.RoomDatabase
import org.koin.core.module.Module
import org.koin.dsl.module

actual fun platformModule(): Module = module {
    single<RoomDatabase.Builder<AppDatabase>> {
        val context = get<Context>()
        Room.databaseBuilder<AppDatabase>(
            context = context,
            name = context.getDatabasePath("readinglist.db").absolutePath,
        )
    }
}

shared/src/iosMain/kotlin/compose/project/demo/PlatformModule.ios.kt:

Kotlin
package compose.project.demo

import androidx.room3.Room
import androidx.room3.RoomDatabase
import kotlinx.cinterop.ExperimentalForeignApi
import org.koin.core.module.Module
import org.koin.dsl.module
import platform.Foundation.NSDocumentDirectory
import platform.Foundation.NSFileManager
import platform.Foundation.NSUserDomainMask

@OptIn(ExperimentalForeignApi::class)
actual fun platformModule(): Module = module {
    single<RoomDatabase.Builder<AppDatabase>> {
        val documentDirectory = NSFileManager.defaultManager.URLForDirectory(
            directory = NSDocumentDirectory,
            inDomain = NSUserDomainMask,
            appropriateForURL = null,
            create = false,
            error = null,
        )
        Room.databaseBuilder<AppDatabase>(
            name = requireNotNull(documentDirectory?.path) + "/readinglist.db",
        )
    }
}

shared/src/jvmMain/kotlin/compose/project/demo/PlatformModule.jvm.kt:

Kotlin
package compose.project.demo

import androidx.room3.Room
import androidx.room3.RoomDatabase
import java.io.File
import org.koin.core.module.Module
import org.koin.dsl.module

actual fun platformModule(): Module = module {
    single<RoomDatabase.Builder<AppDatabase>> {
        val directory = File(System.getProperty("user.home"), ".readinglist")
        directory.mkdirs()
        Room.databaseBuilder<AppDatabase>(
            name = File(directory, "readinglist.db").absolutePath,
        )
    }
}

Reference

Versions used

ComponentVersionWhere it comes from
Kotlin2.4.20Template catalog
Compose Multiplatform1.12.1Template catalog
Android Gradle plugin9.1.1Template catalog
Gradle wrapper9.5.1Template
Lifecycle (ViewModel)2.11.0Template catalog
Material 31.12.0-alpha03Template catalog
Navigation2.10.0-beta01Compose Multiplatform 1.12.1 notes
KSP2.3.12Latest release, 9 September 2026
Room3.0.3Room 3 release page, 9 September 2026
SQLite bundled driver2.7.1Room KMP docs
Ktor client3.6.0Ktor 3.6.0 docs
Koin4.2.2Koin docs

The platform minimums in JetBrains' compatibility table are Android 5.0 (API 21), iOS 14, macOS 13 on arm64, Windows 10 and Ubuntu 20.04. The template sets minSdk to 24 in this project, which is above the Android floor. Desktop needs JDK 11 at minimum, and JDK 17 or later to package native distributions with jpackage.

kotlinx-serialization-json isn't declared in this project. It arrives through Ktor's JSON serialization artifact, and the project compiled with no explicit dependency on it.

Gradle tasks used

TaskWhat it does
./gradlew :shared:compileKotlinJvmCompiles the shared module for the JVM
./gradlew :desktopApp:runRuns the desktop app
./gradlew :desktopApp:hotRun --autoRuns the desktop app with Compose Hot Reload and reloads on save
./gradlew :androidApp:assembleDebugBuilds the Android debug APK
./gradlew :shared:linkDebugFrameworkIosSimulatorArm64Builds the iOS simulator framework

Open Library limits

Open Library's guidelines set 1 request per second by default and 3 per second for requests with an identifying User-Agent, ask you to cache responses where possible, and say the APIs aren't meant as bulk data backends or high-traffic commercial infrastructure. The search parameters used here are q, limit and fields.

Common mistakes

  • Writing room { ... } in shared/build.gradle.kts. With plugin androidx.room3 the extension is room3 (step 1).
  • Copying kspIosX64 from the Room docs. The template has no iosX64 target, so use only the four add("ksp...") lines (step 1).
  • Forgetting the INTERNET permission. The Android build still succeeds, and every search fails at runtime (step 1).
  • Running hotRun without --auto and expecting a reload on save (step 2).
  • Calling viewModel() with no initializer in common code. It doesn't work on non-JVM targets, so pass viewModel { ... } (step 3).
  • Calling setQueryCoroutineContext(Dispatchers.IO) in common code. In our build it failed the iOS compile (step 7).

Next steps

  • Try Navigation 3. The Compose Multiplatform docs describe the org.jetbrains.androidx.navigation3:navigation3-ui artifact in their Navigation 3 guide.
  • Switch Koin to its Compiler Plugin DSL, the form its own quickstart uses.
  • Rename the iOS app. The simulator shows it as ComposeDemo, which comes from PRODUCT_NAME in iosApp/Configuration/Config.xcconfig.
  • Add the web target. It's Beta, and the IDE wizard's Web checkbox generates it.
  • Package the desktop app with packageDmg. That needs JDK 17 or later for jpackage.
  • If you're weighing this stack for a whole team, Kotlin Multiplatform: a CTO's guide to adoption covers the decision. If you're hiring for it, start from Hire developers by tech stack: rates, vetting and interview guides.

FAQ

Is Compose Multiplatform stable for iOS?

JetBrains badges the iOS target Stable and web Beta. This tutorial runs on the iOS simulator on the same code as Android and desktop. For the full picture of what's stable and what isn't, read the Compose Multiplatform stability breakdown rather than relying on a badge alone.

Do you need a Mac to follow this Compose Multiplatform tutorial?

Only for the iOS target. Building and running iOS needs Xcode, which runs on macOS. On Windows or Linux you can complete steps 1 to 7 and 9 for Android and desktop, and skip step 8 (not tested on Windows or Linux). The shared Kotlin code doesn't change.

Why Room 3 and not Room 2.8?

Room 2.8.5 is still a stable release, but the Kotlin Multiplatform page documents Room 3, so this tutorial uses 3.0.3, the stable Room 3 release. The package is androidx.room3 and the Gradle extension is room3, which is why the docs' room { } block doesn't work here.

Do you need a separate Android module?

Yes. The Android KMP library plugin has no direct replacement for the com.android.application plugin inside a multiplatform module, and its docs tell you to extract the Android app into its own Gradle module. That's why the project has androidApp next to shared.

Can you use Navigation 3 instead of Navigation?

Yes, the docs describe navigation3-ui as an alternative. It's a different API, so the NavHost code in step 4 and the routes in App.kt would change. This tutorial uses the Navigation artifact that ships with Compose Multiplatform 1.12.1.

Can you start from the IDE wizard instead?

Yes. In IntelliJ IDEA or Android Studio, use File > New > Project > Kotlin Multiplatform with Android, iOS, Desktop and Share UI selected. The generated files can differ from the pinned template, so this tutorial pins the template to keep every path, package and version in the steps exactly as written.

Share this article

Author Image

HighCircl Editorial Team

The HighCircl editorial team writes about hiring software engineers, nearshore development, and engineering team building. Our articles draw on direct experience sourcing and placing senior developers across Poland, Hungary, Slovakia, Serbia, Slovenia, Romania, and Spain — and on candid conversations with the CTOs and engineering leads who hire them.

HighCircl is a nearshore engineering network that delivers matched candidate shortlists in 72 hours. Every piece of content we publish is informed by real engagement data: actual developer rates, real hiring timelines, and what separates engineering teams that scale cleanly from those that stall.

Take Me to the Experts

Access our network of industry-leading software engineers.

Start Now