Step 15 of 15
Build a complete Task Manager app using Compose, ViewModel, Room, Ktor, Koin DI
Workshop สุดท้าย — สร้าง Task Manager app ครบทุกชั้น: UI, ViewModel, Repository, Room, Ktor, Koin
Build a complete Task Manager Android app with Jetpack Compose. It uses everything from the previous lessons: theming, state, navigation, ViewModel, Koin DI, Room storage, and Ktor sync (mocked).
An app that lets you:
Create a new Empty Activity project named TaskManager, package com.example.taskmanager. Configure the module build file:
// app/build.gradle.kts
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.compose)
alias(libs.plugins.kotlin.serialization)
alias(libs.plugins.google.devtools.ksp)
}
android {
namespace = "com.example.taskmanager"
compileSdk = 35
defaultConfig {
applicationId = "com.example.taskmanager"
minSdk = 24
targetSdk = 35
versionCode = 1
versionName = "1.0"
}
buildFeatures { compose = true }
}
dependencies {
val composeBom = platform("androidx.compose:compose-bom:2025.06.01")
implementation(composeBom)
implementation("androidx.activity:activity-compose:1.10.1")
implementation("androidx.navigation:navigation-compose:2.9.0")
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.9.0")
implementation("androidx.lifecycle:lifecycle-runtime-compose:2.9.0")
implementation("androidx.compose.material3:material3")
// Room
implementation("androidx.room:room-runtime:2.7.0")
implementation("androidx.room:room-ktx:2.7.0")
ksp("androidx.room:room-compiler:2.7.0")
// Ktor
implementation("io.ktor:ktor-client-core:3.1.0")
implementation("io.ktor:ktor-client-cio:3.1.0")
implementation("io.ktor:ktor-client-content-negotiation:3.1.0")
implementation("io.ktor:ktor-serialization-kotlinx-json:3.1.0")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.8.0")
// Koin
implementation("io.insert-koin:koin-android:4.0.0")
implementation("io.insert-koin:koin-androidx-compose:4.0.0")
}
package com.example.taskmanager.ui.theme
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
private val LightColors = lightColorScheme(
primary = Color(0xFF6650A4),
secondary = Color(0xFF625B71)
)
private val DarkColors = darkColorScheme(
primary = Color(0xFFD0BCFF),
secondary = Color(0xFFCCC2DC)
)
@Composable
fun TaskManagerTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
content: @Composable () -> Unit
) {
MaterialTheme(
colorScheme = if (darkTheme) DarkColors else LightColors,
content = content
)
}
package com.example.taskmanager.data
import kotlinx.serialization.Serializable
@Serializable
data class Task(
val id: Int = 0,
val title: String,
val done: Boolean = false
)
Entity:
@Entity(tableName = "tasks")
data class TaskEntity(
@PrimaryKey(autoGenerate = true) val id: Int = 0,
val title: String,
val done: Boolean = false
)
DAO:
@Dao
interface TaskDao {
@Query("SELECT * FROM tasks ORDER BY id DESC")
fun observeAll(): Flow<List<TaskEntity>>
@Insert
suspend fun insert(task: TaskEntity): Long
@Update
suspend fun update(task: TaskEntity)
@Query("DELETE FROM tasks WHERE id = :id")
suspend fun deleteById(id: Int)
}
Database:
@Database(entities = [TaskEntity::class], version = 1, exportSchema = false)
abstract class AppDatabase : RoomDatabase() {
abstract fun taskDao(): TaskDao
}
The repository maps entities to domain Task objects. The SyncService is a mock Ktor call — it returns success after a short delay.
interface TaskRepository {
fun observeAll(): Flow<List<Task>>
suspend fun add(title: String)
suspend fun toggleDone(task: Task)
suspend fun delete(id: Int)
}
class RoomTaskRepository(private val dao: TaskDao) : TaskRepository {
override fun observeAll(): Flow<List<Task>> =
dao.observeAll().map { list -> list.map { Task(it.id, it.title, it.done) } }
override suspend fun add(title: String) {
dao.insert(TaskEntity(title = title))
}
override suspend fun toggleDone(task: Task) {
dao.update(TaskEntity(task.id, task.title, !task.done))
}
override suspend fun delete(id: Int) {
dao.deleteById(id)
}
}
class SyncService(private val client: HttpClient) {
suspend fun push(tasks: List<Task>): Boolean {
delay(500) // mock network latency
return true // mock success
}
}
data class TaskUiState(
val tasks: List<Task> = emptyList(),
val syncMessage: String = ""
)
class TaskViewModel(
private val repository: TaskRepository,
private val sync: SyncService
) : ViewModel() {
private val _uiState = MutableStateFlow(TaskUiState())
val uiState: StateFlow<TaskUiState> = _uiState.asStateFlow()
init {
viewModelScope.launch {
repository.observeAll().collect { tasks ->
_uiState.update { it.copy(tasks = tasks) }
}
}
}
fun add(title: String) {
if (title.isBlank()) return
viewModelScope.launch { repository.add(title.trim()) }
}
fun toggleDone(task: Task) {
viewModelScope.launch { repository.toggleDone(task) }
}
fun delete(id: Int) {
viewModelScope.launch { repository.delete(id) }
}
fun sync() {
viewModelScope.launch {
val ok = sync.push(_uiState.value.tasks)
_uiState.update {
it.copy(syncMessage = if (ok) "Synced" else "Sync failed")
}
}
}
}
val databaseModule = module {
single { Room.databaseBuilder(androidContext(), AppDatabase::class.java, "tasks.db").build() }
single { get<AppDatabase>().taskDao() }
}
val networkModule = module {
single {
HttpClient(CIO) {
install(ContentNegotiation) { json(Json { ignoreUnknownKeys = true }) }
}
}
single { SyncService(get()) }
}
val appModule = module {
single<TaskRepository> { RoomTaskRepository(get()) }
viewModelOf(::TaskViewModel)
}
Start Koin in the Application:
class TaskManagerApp : Application() {
override fun onCreate() {
super.onCreate()
startKoin {
androidContext(this@TaskManagerApp)
modules(databaseModule, networkModule, appModule)
}
}
}
Task list screen:
@Composable
fun TaskListScreen(
onAddClick: () -> Unit,
viewModel: TaskViewModel = koinViewModel()
) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
Scaffold(
floatingActionButton = {
FloatingActionButton(onClick = onAddClick) { Text("+") }
}
) { padding ->
Column(Modifier.padding(padding)) {
Text(uiState.syncMessage)
LazyColumn(Modifier.fillMaxSize()) {
items(uiState.tasks, key = { it.id }) { task ->
TaskRow(task, onToggle = { viewModel.toggleDone(task) }, onDelete = { viewModel.delete(task.id) })
}
}
}
}
}
@Composable
fun TaskRow(task: Task, onToggle: () -> Unit, onDelete: () -> Unit) {
Row(Modifier.fillMaxWidth().padding(16.dp), verticalAlignment = Alignment.CenterVertically) {
Checkbox(checked = task.done, onCheckedChange = { onToggle() })
Spacer(Modifier.width(8.dp))
Text(task.title, Modifier.weight(1f), textDecoration = if (task.done) TextDecoration.LineThrough else null)
TextButton(onClick = onDelete) { Text("Delete") }
}
}
Add task screen:
@Composable
fun AddTaskScreen(
onSaved: () -> Unit,
viewModel: TaskViewModel = koinViewModel()
) {
var title by rememberSaveable { mutableStateOf("") }
Column(Modifier.padding(16.dp)) {
TextField(value = title, onValueChange = { title = it }, label = { Text("Task title") })
Spacer(Modifier.height(8.dp))
Button(onClick = { viewModel.add(title); onSaved() }) { Text("Save") }
}
}
@Composable
fun TaskApp() {
val navController = rememberNavController()
NavHost(navController, startDestination = "list") {
composable("list") {
TaskListScreen(onAddClick = { navController.navigate("add") })
}
composable("add") {
AddTaskScreen(onSaved = { navController.popBackStack() })
}
}
}
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
TaskManagerTheme {
Surface { TaskApp() }
}
}
}
}
Register the Application in the manifest:
<application android:name=".TaskManagerApp" ...>
<activity android:name=".MainActivity" android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
Add the internet permission for the mock sync:
<uses-permission android:name="android.permission.INTERNET" />
// After adding two tasks and rotating:
// [ ] Learn Compose
// [x] Buy milk
priority field and sort the listSyncService to a real endpoint and POST tasks| Feature | Where you used it |
|---|---|
| Compose UI | Screens, LazyColumn, Scaffold, modifiers |
| State | collectAsStateWithLifecycle, rememberSaveable |
| Navigation | NavHost, composable routes, popBackStack |
| ViewModel + StateFlow | TaskViewModel, TaskUiState |
| Repository pattern | TaskRepository interface + RoomTaskRepository |
| Room | Entity, DAO, Database, Flow queries |
| Ktor | HttpClient, ContentNegotiation, mock SyncService |
| Koin DI | modules, viewModelOf, koinViewModel() |
| Theming | TaskManagerTheme, light/dark color schemes |
Previous
Android Security
Final step