Task Manager Desktop and Web App
Write the UI once in commonMain and ship it as both a native desktop window
and a WebAssembly browser app. The same Compose code drives both targets; only the
tiny entry points differ. The app is a task manager that talks to the REST API you
built back in Module 08/09 over a shared Ktor client.
If you’ve used React, this is the payoff a TS dev recognizes: declarative UI, component functions, hoisted state, one render tree — except the same source compiles to a desktop binary and the web.
What you’ll build
Section titled “What you’ll build”A Material 3 task manager with:
- Task list — every task shown with title, description, a priority badge, and a completion checkbox.
- Create task — a dialog with title, description, and priority chips.
- Toggle completion — a checkbox that flips a task complete/incomplete.
- Delete tasks — a delete button on each card.
- Refresh — a button to reload from the API.
- Error handling — an error message with a Retry button when the API is down.
- Theme — Material 3 with a dark/light toggle.
The worked solution
Section titled “The worked solution”Everything user-facing lives in commonMain. The two Main.kt files under
desktopMain and wasmJsMain are each ~10 lines — they just hand a window to the
shared App() composable.
Directorytask-manager-app/
- build.gradle.kts multiplatform + compose plugins
- settings.gradle.kts google + mavenCentral repos
Directorysrc/
DirectorycommonMain/kotlin/ shared code, runs on every target
- App.kt root composable, wires state to UI
Directorydata/
- model/Task.kt serializable data models
- api/TaskApi.kt Ktor HTTP client
- viewmodel/TaskViewModel.kt StateFlow state holder
Directoryui/
- components/TaskCard.kt one task row
- screens/TaskListScreen.kt list + create dialog
- theme/AppTheme.kt Material 3 color schemes
- desktopMain/kotlin/Main.kt desktop entry point
DirectorywasmJsMain/
- kotlin/Main.kt web entry point
- resources/index.html HTML host page
build.gradle.kts — one project, two targets
Section titled “build.gradle.kts — one project, two targets”The Kotlin Multiplatform plugin declares the targets (jvm("desktop") and
wasmJs), and each target gets its own source set with target-specific
dependencies. The shared commonMain depends only on the Compose runtime and a
platform-agnostic Ktor client core; each platform supplies its own Ktor engine
(ktor-client-cio on the JVM, ktor-client-js for the browser).
plugins { kotlin("multiplatform") version "2.1.0" kotlin("plugin.serialization") version "2.1.0" id("org.jetbrains.compose") version "1.7.3" id("org.jetbrains.kotlin.plugin.compose") version "2.1.0"}
kotlin { jvm("desktop")
wasmJs { browser() }
sourceSets { val commonMain by getting { dependencies { implementation(compose.runtime) implementation(compose.foundation) implementation(compose.material3) implementation(compose.components.resources)
// Ktor client for API calls (engine-agnostic core) implementation("io.ktor:ktor-client-core:3.0.3") implementation("io.ktor:ktor-client-content-negotiation:3.0.3") implementation("io.ktor:ktor-serialization-kotlinx-json:3.0.3")
// Coroutines implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.9.0") implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3") } }
val desktopMain by getting { dependencies { implementation(compose.desktop.currentOs) implementation("io.ktor:ktor-client-cio:3.0.3") } }
val wasmJsMain by getting { dependencies { implementation("io.ktor:ktor-client-js:3.0.3") } } }}
compose.desktop { application { mainClass = "MainKt" }}data/model/Task.kt — serializable models
Section titled “data/model/Task.kt — serializable models”Plain Kotlin data classes with @Serializable. These double as the wire format
(deserialized straight from the API JSON) and the UI model. description and
createdAt are nullable (String?); defaults like priority = Priority.MEDIUM
fill in fields the server omits. CreateTaskRequest and UpdateTaskRequest are the
request bodies — note UpdateTaskRequest makes every field nullable so a PUT can
send just the one field that changed (here, completed).
package data.model
import kotlinx.serialization.Serializable
@Serializabledata class Task( val id: String, val title: String, val description: String? = null, val priority: Priority = Priority.MEDIUM, val completed: Boolean = false, val createdAt: String? = null)
@Serializableenum class Priority { LOW, MEDIUM, HIGH }
@Serializabledata class CreateTaskRequest( val title: String, val description: String? = null, val priority: Priority = Priority.MEDIUM)
@Serializabledata class UpdateTaskRequest( val title: String? = null, val description: String? = null, val priority: Priority? = null, val completed: Boolean? = null)data/api/TaskApi.kt — the shared HTTP client
Section titled “data/api/TaskApi.kt — the shared HTTP client”A thin wrapper over Ktor’s HttpClient. ContentNegotiation with the JSON
serializer means every suspend fun returns typed Kotlin objects — client.get(...).body()
infers List<Task> from the call site, the way fetch().then(r => r.json()) would in
TS but type-checked. This file is in commonMain, so the exact same client code runs
on desktop and in the browser; only the underlying engine differs (configured in
build.gradle.kts).
package data.api
import data.model.*import io.ktor.client.*import io.ktor.client.call.*import io.ktor.client.plugins.contentnegotiation.*import io.ktor.client.request.*import io.ktor.http.*import io.ktor.serialization.kotlinx.json.*import kotlinx.serialization.json.Json
class TaskApi(private val baseUrl: String = "http://localhost:8080") { private val client = HttpClient { install(ContentNegotiation) { json(Json { ignoreUnknownKeys = true isLenient = true }) } }
suspend fun getTasks(): List<Task> = client.get("$baseUrl/api/tasks").body()
suspend fun getTask(id: String): Task = client.get("$baseUrl/api/tasks/$id").body()
suspend fun createTask(request: CreateTaskRequest): Task = client.post("$baseUrl/api/tasks") { contentType(ContentType.Application.Json) setBody(request) }.body()
suspend fun updateTask(id: String, request: UpdateTaskRequest): Task = client.put("$baseUrl/api/tasks/$id") { contentType(ContentType.Application.Json) setBody(request) }.body()
suspend fun deleteTask(id: String) { client.delete("$baseUrl/api/tasks/$id") }}data/viewmodel/TaskViewModel.kt — state as a StateFlow
Section titled “data/viewmodel/TaskViewModel.kt — state as a StateFlow”This is the state-hoisting heart of the app. Instead of scattering UI state across
composables, the screen reads a single StateFlow<TaskListState> and the ViewModel
owns all the mutations. TaskListState is a sealed interface with three cases —
Loading, Success(tasks), Error(message) — so the UI just exhaustively whens
over it (no “is it loading AND has data AND has an error?” boolean soup).
Each action (loadTasks, createTask, toggleTask, deleteTask) launches a
coroutine on the ViewModel’s own CoroutineScope, calls the suspend API, and pushes
a new state. After a mutation it just calls loadTasks() again to re-sync from the
server — the simplest correct strategy.
package data.viewmodel
import data.api.TaskApiimport data.model.*import kotlinx.coroutines.*import kotlinx.coroutines.flow.*
sealed interface TaskListState { data object Loading : TaskListState data class Success(val tasks: List<Task>) : TaskListState data class Error(val message: String) : TaskListState}
class TaskViewModel(private val api: TaskApi) { private val _state = MutableStateFlow<TaskListState>(TaskListState.Loading) val state: StateFlow<TaskListState> = _state.asStateFlow()
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
fun loadTasks() { scope.launch { _state.value = TaskListState.Loading try { val tasks = api.getTasks() _state.value = TaskListState.Success(tasks) } catch (e: Exception) { _state.value = TaskListState.Error(e.message ?: "Failed to load tasks") } } }
fun createTask(request: CreateTaskRequest) { scope.launch { try { api.createTask(request) loadTasks() } catch (e: Exception) { _state.value = TaskListState.Error(e.message ?: "Failed to create task") } } }
fun toggleTask(task: Task) { scope.launch { try { api.updateTask(task.id, UpdateTaskRequest(completed = !task.completed)) loadTasks() } catch (e: Exception) { _state.value = TaskListState.Error(e.message ?: "Failed to update task") } } }
fun deleteTask(id: String) { scope.launch { try { api.deleteTask(id) loadTasks() } catch (e: Exception) { _state.value = TaskListState.Error(e.message ?: "Failed to delete task") } } }}ui/theme/AppTheme.kt — Material 3 light and dark
Section titled “ui/theme/AppTheme.kt — Material 3 light and dark”Two hard-coded ColorSchemes and a wrapper composable. The darkTheme boolean
picks one and feeds it to MaterialTheme; everything below pulls colors from
MaterialTheme.colorScheme, so a single boolean flips the whole UI.
package ui.theme
import androidx.compose.material3.*import androidx.compose.runtime.Composableimport androidx.compose.ui.graphics.Color
private val LightColorScheme = lightColorScheme( primary = Color(0xFF6750A4), onPrimary = Color(0xFFFFFFFF), primaryContainer = Color(0xFFEADDFF), secondary = Color(0xFF625B71), background = Color(0xFFFEF7FF), surface = Color(0xFFFEF7FF), surfaceVariant = Color(0xFFE7E0EC), error = Color(0xFFB3261E),)
private val DarkColorScheme = darkColorScheme( primary = Color(0xFFD0BCFF), onPrimary = Color(0xFF381E72), primaryContainer = Color(0xFF4F378B), secondary = Color(0xFFCCC2DC), background = Color(0xFF1C1B1F), surface = Color(0xFF1C1B1F), surfaceVariant = Color(0xFF49454F), error = Color(0xFFF2B8B5),)
@Composablefun AppTheme( darkTheme: Boolean = false, content: @Composable () -> Unit) { MaterialTheme( colorScheme = if (darkTheme) DarkColorScheme else LightColorScheme, content = content )}ui/components/TaskCard.kt — one row, fully presentational
Section titled “ui/components/TaskCard.kt — one row, fully presentational”TaskCard is a pure UI function: it takes a Task and two callbacks
(onToggle: () -> Unit, onDelete: () -> Unit) and owns no state. That’s the
React “controlled component” pattern — the parent decides what happens, the card
just renders and reports clicks. Note the conditional styling: a completed task gets
a faded container and a TextDecoration.LineThrough title. task.description?.let { ... }
only renders the description block when it’s non-null.
package ui.components
import androidx.compose.foundation.layout.*import androidx.compose.foundation.shape.RoundedCornerShapeimport androidx.compose.material.icons.Iconsimport androidx.compose.material.icons.filled.Deleteimport androidx.compose.material3.*import androidx.compose.runtime.Composableimport androidx.compose.ui.Alignmentimport androidx.compose.ui.Modifierimport androidx.compose.ui.text.style.TextDecorationimport androidx.compose.ui.unit.dpimport data.model.Priorityimport data.model.Task
@Composablefun TaskCard( task: Task, onToggle: () -> Unit, onDelete: () -> Unit) { Card( modifier = Modifier.fillMaxWidth(), colors = CardDefaults.cardColors( containerColor = if (task.completed) MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f) else MaterialTheme.colorScheme.surfaceVariant ) ) { Row( modifier = Modifier.padding(16.dp), verticalAlignment = Alignment.CenterVertically ) { Checkbox( checked = task.completed, onCheckedChange = { onToggle() } ) Column(modifier = Modifier.weight(1f).padding(start = 12.dp)) { Text( text = task.title, style = MaterialTheme.typography.titleMedium, textDecoration = if (task.completed) TextDecoration.LineThrough else null ) task.description?.let { Text( text = it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant ) } PriorityBadge(task.priority) } IconButton(onClick = onDelete) { Icon( Icons.Default.Delete, contentDescription = "Delete", tint = MaterialTheme.colorScheme.error ) } } }}
@Composablefun PriorityBadge(priority: Priority) { val (color, label) = when (priority) { Priority.HIGH -> MaterialTheme.colorScheme.error to "High" Priority.MEDIUM -> MaterialTheme.colorScheme.tertiary to "Medium" Priority.LOW -> MaterialTheme.colorScheme.outline to "Low" }
Surface( color = color.copy(alpha = 0.1f), shape = RoundedCornerShape(4.dp) ) { Text( text = label, modifier = Modifier.padding(horizontal = 8.dp, vertical = 2.dp), style = MaterialTheme.typography.labelSmall, color = color ) }}ui/screens/TaskListScreen.kt — the screen and the create dialog
Section titled “ui/screens/TaskListScreen.kt — the screen and the create dialog”The screen reads state with val state by viewModel.state.collectAsState() — this
subscribes the composable to the StateFlow, so any new state recomposes the UI
automatically (no manual setState, no listeners to unregister). The when (val s = state)
exhaustively renders the three states: a spinner for Loading, a message + Retry for
Error, and the LazyColumn of TaskCards for Success (with an empty-state
fallback).
Local UI-only state — whether the create dialog is open — stays in the composable
via var showCreateDialog by remember { mutableStateOf(false) }. That’s the rule of
thumb: server data lives in the ViewModel; ephemeral view state lives in remember.
package ui.screens
import androidx.compose.foundation.layout.*import androidx.compose.foundation.lazy.LazyColumnimport androidx.compose.foundation.lazy.itemsimport androidx.compose.material.icons.Iconsimport androidx.compose.material.icons.filled.Addimport androidx.compose.material.icons.filled.Refreshimport androidx.compose.material3.*import androidx.compose.runtime.*import androidx.compose.ui.Alignmentimport androidx.compose.ui.Modifierimport androidx.compose.ui.unit.dpimport data.model.CreateTaskRequestimport data.model.Priorityimport data.model.Taskimport data.viewmodel.TaskListStateimport data.viewmodel.TaskViewModelimport ui.components.TaskCard
@Composablefun TaskListScreen( viewModel: TaskViewModel, darkTheme: Boolean, onToggleTheme: () -> Unit) { val state by viewModel.state.collectAsState() var showCreateDialog by remember { mutableStateOf(false) }
Scaffold( topBar = { TopAppBar( title = { Text("Task Manager") }, actions = { IconButton(onClick = onToggleTheme) { Text(if (darkTheme) "Light" else "Dark") } IconButton(onClick = { viewModel.loadTasks() }) { Icon(Icons.Default.Refresh, "Refresh") } } ) }, floatingActionButton = { FloatingActionButton(onClick = { showCreateDialog = true }) { Icon(Icons.Default.Add, "Add task") } } ) { padding -> Box(modifier = Modifier.padding(padding).fillMaxSize()) { when (val s = state) { is TaskListState.Loading -> { CircularProgressIndicator( modifier = Modifier.align(Alignment.Center) ) } is TaskListState.Error -> { Column( modifier = Modifier.align(Alignment.Center), horizontalAlignment = Alignment.CenterHorizontally ) { Text( text = s.message, color = MaterialTheme.colorScheme.error ) Spacer(modifier = Modifier.height(8.dp)) Button(onClick = { viewModel.loadTasks() }) { Text("Retry") } } } is TaskListState.Success -> { if (s.tasks.isEmpty()) { Text( text = "No tasks yet. Create one!", modifier = Modifier.align(Alignment.Center), style = MaterialTheme.typography.bodyLarge ) } else { LazyColumn( contentPadding = PaddingValues(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp) ) { items(s.tasks, key = { it.id }) { task -> TaskCard( task = task, onToggle = { viewModel.toggleTask(task) }, onDelete = { viewModel.deleteTask(task.id) } ) } } } } } } }
if (showCreateDialog) { CreateTaskDialog( onDismiss = { showCreateDialog = false }, onCreate = { request -> viewModel.createTask(request) showCreateDialog = false } ) }}
@Composablefun CreateTaskDialog( onDismiss: () -> Unit, onCreate: (CreateTaskRequest) -> Unit) { var title by remember { mutableStateOf("") } var description by remember { mutableStateOf("") } var priority by remember { mutableStateOf(Priority.MEDIUM) }
AlertDialog( onDismissRequest = onDismiss, title = { Text("Create Task") }, text = { Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { OutlinedTextField( value = title, onValueChange = { title = it }, label = { Text("Title") }, modifier = Modifier.fillMaxWidth() ) OutlinedTextField( value = description, onValueChange = { description = it }, label = { Text("Description (optional)") }, modifier = Modifier.fillMaxWidth(), minLines = 2 ) Text("Priority", style = MaterialTheme.typography.labelMedium) Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { Priority.entries.forEach { p -> FilterChip( selected = priority == p, onClick = { priority = p }, label = { Text(p.name.lowercase().replaceFirstChar { it.uppercase() }) } ) } } } }, confirmButton = { Button( onClick = { onCreate( CreateTaskRequest( title = title.trim(), description = description.trim().ifBlank { null }, priority = priority ) ) }, enabled = title.isNotBlank() ) { Text("Create") } }, dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } } )}App.kt — the root composable
Section titled “App.kt — the root composable”App() is the one composable every target mounts. It creates the TaskApi and
TaskViewModel once with remember (so they survive recomposition), holds the
darkTheme flag as state, kicks off the first load in a LaunchedEffect(Unit) (runs
once when the composable enters), and wraps everything in AppTheme. The theme
toggle is hoisted here and passed down as onToggleTheme = { darkTheme = !darkTheme }.
import androidx.compose.foundation.layout.*import androidx.compose.material3.*import androidx.compose.runtime.*import androidx.compose.ui.Modifierimport data.api.TaskApiimport data.viewmodel.TaskViewModelimport ui.screens.TaskListScreenimport ui.theme.AppTheme
@Composablefun App() { val api = remember { TaskApi() } val viewModel = remember { TaskViewModel(api) }
var darkTheme by remember { mutableStateOf(false) }
LaunchedEffect(Unit) { viewModel.loadTasks() }
AppTheme(darkTheme = darkTheme) { Surface( modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background ) { TaskListScreen( viewModel = viewModel, darkTheme = darkTheme, onToggleTheme = { darkTheme = !darkTheme } ) } }}The two entry points
Section titled “The two entry points”This is the only place the targets diverge — and it’s tiny. Desktop opens a native
OS Window; the web target mounts a CanvasBasedWindow onto the <canvas> element
in index.html. Both call the identical App().
import Appimport androidx.compose.ui.window.Windowimport androidx.compose.ui.window.application
fun main() = application { Window( onCloseRequest = ::exitApplication, title = "Task Manager" ) { App() }}import Appimport androidx.compose.ui.ExperimentalComposeUiApiimport androidx.compose.ui.window.CanvasBasedWindow
@OptIn(ExperimentalComposeUiApi::class)fun main() { CanvasBasedWindow(canvasElementId = "ComposeTarget", title = "Task Manager") { App() }}The web target needs an HTML host page. The <canvas id="ComposeTarget"> matches the
canvasElementId above, and the script tag loads the compiled output (named after the
Gradle project, task-manager-app.js).
<!DOCTYPE html><html><head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Task Manager</title> <style> html, body { margin: 0; padding: 0; width: 100%; height: 100%; overflow: hidden; } canvas { display: block; } </style></head><body> <canvas id="ComposeTarget"></canvas> <script src="task-manager-app.js"></script></body></html>Run it
Section titled “Run it”Make sure the task API is running on http://localhost:8080 first (see Module 08/09).
-
Launch the native desktop app:
Terminal window ./gradlew desktopRun -
Or run the web app on a development server, then open the printed URL in a browser:
Terminal window ./gradlew wasmJsBrowserDevelopmentRun -
To produce a deployable web bundle:
Terminal window ./gradlew wasmJsBrowserDistribution# Output in build/dist/wasmJs/productionExecutable/
Verify both targets: tasks load into the list, creating adds one, the checkbox toggles completion, delete removes a card, and stopping the API surfaces the error state with a working Retry button.