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):
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.ktAfter 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_HOMEvariable 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 -rfand./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:
git --version
xcodebuild -version
echo $ANDROID_HOMEQuickstart
Want to see a window before you read anything else? Clone JetBrains' template and run its desktop app:
git clone https://github.com/kotlin-hands-on/get-started-with-cm ReadingList
cd ReadingList
git checkout ca0b6d2
./gradlew :desktopApp:runThe 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 --autofor UI work. You'll use it in step 2. - Keep all three Gradle modules the template has (
shared,androidApp,desktopApp) plusiosApp. The UI and logic live inshared.
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:
git clone https://github.com/kotlin-hands-on/get-started-with-cm ReadingList
cd ReadingList
git checkout ca0b6d2The template has a web app and tests this tutorial doesn't use. Remove them, along with the sample Greeting code, from the project root:
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.ktIn settings.gradle.kts, delete the line include(":webApp") and set the project name by changing the rootProject.name line to:
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:
[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:
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.
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:
<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:
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:
./gradlew :shared:compileKotlinJvmYou should see something like (the time will differ):
BUILD SUCCESSFUL in 1m 12sThen run the desktop app:
./gradlew :desktopApp:runA 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:
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:
./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:
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.
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:
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.
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:
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:
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:
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:
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:
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:
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:
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:
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:
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:
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:
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:
package compose.project.demo
import org.koin.core.module.Module
expect fun platformModule(): ModuleCreate 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:
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:
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:
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:
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:
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:
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:
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:
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:
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:
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:
./gradlew :shared:linkDebugFrameworkIosSimulatorArm64You should see something like (the time will differ):
BUILD SUCCESSFUL in 2m 8sThen 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:
./gradlew :desktopApp:runSearch 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:
test -f ~/.readinglist/readinglist.db && echo foundYou should see something like:
foundSearch 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:
./gradlew :androidApp:assembleDebugYou should see something like (the time will differ):
BUILD SUCCESSFUL in 1m 4sRun 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):
Could not load books: Unable to resolve host "openlibrary.org": No address associated with hostnameTurn airplane mode off and the next search works without restarting.
Architecture
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 desktopClean up
Stop the desktop app by closing its window, or press Ctrl+C in the terminal running Gradle. Stop the Gradle daemons:
./gradlew --stopUninstall 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:
rm -rf ~/.readinglistIf 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.
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.ktClone 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:
./gradlew :shared:compileKotlinJvm
./gradlew :desktopApp:run
./gradlew :androidApp:assembleDebug
./gradlew :shared:linkDebugFrameworkIosSimulatorArm64For iOS, open iosApp/iosApp.xcodeproj in Xcode and run on a simulator.
Gradle files
gradle/libs.versions.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:
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:
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):
<?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:
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:
package compose.project.demo
data class Book(
val id: String,
val title: String,
val authors: String,
val year: Int?,
)BookApi.kt:
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:
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:
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:
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:
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:
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:
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:
package compose.project.demo
import org.koin.core.module.Module
expect fun platformModule(): ModuleApp.kt:
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:
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:
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:
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
| Component | Version | Where it comes from |
|---|---|---|
| Kotlin | 2.4.20 | Template catalog |
| Compose Multiplatform | 1.12.1 | Template catalog |
| Android Gradle plugin | 9.1.1 | Template catalog |
| Gradle wrapper | 9.5.1 | Template |
| Lifecycle (ViewModel) | 2.11.0 | Template catalog |
| Material 3 | 1.12.0-alpha03 | Template catalog |
| Navigation | 2.10.0-beta01 | Compose Multiplatform 1.12.1 notes |
| KSP | 2.3.12 | Latest release, 9 September 2026 |
| Room | 3.0.3 | Room 3 release page, 9 September 2026 |
| SQLite bundled driver | 2.7.1 | Room KMP docs |
| Ktor client | 3.6.0 | Ktor 3.6.0 docs |
| Koin | 4.2.2 | Koin 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
| Task | What it does |
|---|---|
./gradlew :shared:compileKotlinJvm | Compiles the shared module for the JVM |
./gradlew :desktopApp:run | Runs the desktop app |
./gradlew :desktopApp:hotRun --auto | Runs the desktop app with Compose Hot Reload and reloads on save |
./gradlew :androidApp:assembleDebug | Builds the Android debug APK |
./gradlew :shared:linkDebugFrameworkIosSimulatorArm64 | Builds 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 { ... }inshared/build.gradle.kts. With pluginandroidx.room3the extension isroom3(step 1). - Copying
kspIosX64from the Room docs. The template has noiosX64target, so use only the fouradd("ksp...")lines (step 1). - Forgetting the INTERNET permission. The Android build still succeeds, and every search fails at runtime (step 1).
- Running
hotRunwithout--autoand expecting a reload on save (step 2). - Calling
viewModel()with no initializer in common code. It doesn't work on non-JVM targets, so passviewModel { ... }(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-uiartifact 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_NAMEiniosApp/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 forjpackage. - 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.
