Build a Task Manager App in Kotlin

Build an Android Task Manager app with Clean Architecture and Hilt DI.

Introduction

30 Second Summary

Every app on your phone manages data, screens, and user actions behind the scenes. The difference between a messy app and a professional one is how that code is organized.

In this project, you will build a Task Manager Android app using Clean Architecture, where each layer of your code has a clear responsibility. You will wire everything together with dependency injection, persist data in a local database, and navigate between screens.

What You'll Build

A multi-screen Task Manager app with local database persistence, dependency injection, and reactive state management built entirely with Jetpack Compose and Kotlin.

By the end of this project, you'll have:

  • A Clean Architecture folder structure separating your code into presentation, domain, and data layers.
  • ViewModel + StateFlow state management with Hilt dependency injection wiring all layers together.
  • A Room database persisting tasks with full CRUD operations and Navigation Compose for multi-screen navigation.
  • Secret Mission: Add task filtering by priority and a search feature.

Are there any prerequisites?

This project assumes you have basic familiarity with Kotlin syntax and have Android Studio installed. If you have written a simple "Hello World" app before, you are ready to go.

Create Project and Configure Dependencies

Every professional Android app starts with a solid foundation of tools and libraries. Before you write a single line of app logic, you need to wire up the architecture tools that make production-quality code possible.

In this step, you will create your Android Studio project and configure all the dependencies your Task Manager needs. This includes Hilt for dependency injection, Room for database storage, and Navigation Compose for moving between screens.

In this step, get ready to:
  • Create a new Android Studio project with Jetpack Compose.
  • Configure project-level and app-level Gradle dependencies.
  • Create the Clean Architecture folder structure.
Create a new Android Studio project

You will start by creating a new project using the Empty Activity template, which comes pre-configured for Jetpack Compose.

  • In the Android Studio Welcome screen, click New Project.
  • Make sure the Phone and Tablet tab is selected.
  • Select Empty Activity and click Next.
  • Set the Name to Task Manager.
  • Set the Package name to com.example.taskmanager.
  • Set the Minimum SDK to API 26 (Android 8.0).
  • Leave the Build configuration language as Kotlin DSL.
  • Click Finish.

Android Studio will create your project and run an initial Gradle sync. Wait for the sync to complete before continuing.

What did Empty Activity give you?

The Empty Activity template pre-configures your project with Jetpack Compose, including the Compose Compiler Gradle plugin and basic Material Design 3 theming. You get a working MainActivity.kt with setContent already set up.

Gradle sync failed?

Make sure you have an active internet connection. Android Studio needs to download dependencies during the first sync. If you see a JDK error, go to File > Settings > Build, Execution, Deployment > Build Tools > Gradle and confirm the Gradle JDK is set to JDK 17 or higher.

Ask for help: My Android Studio Gradle sync is failing during project creation. What should I check?

Configure project-level Gradle with Hilt and KSP plugins

Your project needs two additional Gradle plugins that are not included in the default template. Hilt handles dependency injection, and KSP (Kotlin Symbol Processing) is the annotation processor that Hilt and Room use to generate code at compile time.

  • In the Project panel on the left, expand Gradle Scripts.
  • Open the file labeled build.gradle.kts (Project: Task_Manager). This is the project-level build file.
  • Find the plugins block and add the Hilt and KSP plugins below the existing entries:
plugins {
    // ... existing plugins from template (do not delete them)
    id("com.google.dagger.hilt.android") version "2.59.2" apply false
    id("com.google.devtools.ksp") version "2.3.9" apply false
}

What does this code do?

  • id("com.google.dagger.hilt.android") registers the Hilt plugin so your app module can use it for dependency injection.
  • id("com.google.devtools.ksp") registers KSP, which generates boilerplate code from annotations at compile time. Both Hilt and Room rely on it.
  • apply false means these plugins are declared at the project level but only activated in modules that explicitly opt in.
  • Save the file.
Configure app-level Gradle with all dependencies

Now you will add all the libraries your Task Manager needs to the app-level build file. This includes the Compose BOM (Bill of Materials), Hilt, Room, Navigation Compose, and Lifecycle ViewModel.

  • Open build.gradle.kts (Module :app) from the Gradle Scripts section.
  • In the plugins block at the top, add the Hilt and KSP plugins below the existing ones:
plugins {
    // ... existing plugins from template
    id("com.google.dagger.hilt.android")
    id("com.google.devtools.ksp")
}

What does this code do?

These lines activate the Hilt and KSP plugins specifically in this app module. You do not need a version here because the project-level file already declared it.

  • Inside the android block, make sure compileOptions is set to Java 17 (required by Hilt):
android {
    // ... other existing config

    compileOptions {
        sourceCompatibility = JavaVersion.VERSION_17
        targetCompatibility = JavaVersion.VERSION_17
    }
    kotlinOptions {
        jvmTarget = "17"
    }
}

Why Java 17?

Hilt 2.59.2 requires Java 17 for compilation. If you leave the default at Java 11 or lower, Hilt's annotation processing will fail during build.

  • Now find the dependencies block and add the following libraries below the existing Compose dependencies:
dependencies {
    // ... keep existing Compose dependencies from template

    // Compose BOM - manages Compose library versions
    val composeBom = platform("androidx.compose:compose-bom:2026.06.00")
    implementation(composeBom)
    androidTestImplementation(composeBom)

    // Material Design 3
    implementation("androidx.compose.material3:material3")

    // Hilt - dependency injection
    implementation("com.google.dagger:hilt-android:2.59.2")
    ksp("com.google.dagger:hilt-compiler:2.59.2")
    implementation("androidx.hilt:hilt-lifecycle-viewmodel-compose:1.3.0")

    // Room - local database
    implementation("androidx.room:room-runtime:2.8.4")
    implementation("androidx.room:room-ktx:2.8.4")
    ksp("androidx.room:room-compiler:2.8.4")

    // Navigation Compose - screen navigation
    implementation("androidx.navigation:navigation-compose:2.9.8")

    // Lifecycle ViewModel Compose - ViewModel integration
    implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.10.0")
}

What does this code do?

  • The Compose BOM 2026.06.00 ensures all Compose libraries use compatible versions without you specifying each one individually.
  • hilt-android and hilt-compiler provide dependency injection. The ksp() call tells KSP to process Hilt annotations at compile time.
  • hilt-lifecycle-viewmodel-compose provides the hiltViewModel() function you will use in composables to obtain injected ViewModels.
  • room-runtime and room-ktx provide the database layer with Kotlin coroutines support. The room-compiler generates implementation code from your annotations.
  • navigation-compose handles navigation between screens using composable destinations.
  • lifecycle-viewmodel-compose integrates ViewModels with Compose's lifecycle.
  • Save the file and click Sync Now in the notification bar at the top of the editor.
  • Wait for the Gradle sync to complete successfully. You should see a green checkmark in the Build output panel.

Sync failed with dependency errors?

  • Make sure the version numbers match exactly. A typo in a version string (like 2.59.2 vs 2.5.92) will cause resolution failures.
  • Confirm that the plugins block in the project-level file has apply false and the app-level file does NOT have apply false.
  • If you see a Java version error, double-check that compileOptions and kotlinOptions both target Java 17.

Help me fix a Gradle sync failure after adding Hilt, Room, and KSP dependencies.

Create the Clean Architecture folder structure

Clean Architecture separates your code into layers with clear responsibilities. You will create folders for three layers: domain (business logic), data (database and repositories), and presentation (UI and ViewModels).

  • In the Project panel, switch to Android view using the dropdown at the top.
  • Expand app > java > com.example.taskmanager.
  • Right-click on com.example.taskmanager and select New > Package.
  • Create the following packages one at a time (right-click > New > Package for each):
data.local
data.repository
domain.model
domain.repository
domain.usecase
presentation.tasklist
presentation.addtask
presentation.navigation
di

What is each layer for?

  • domain/ contains your business logic. It has no Android dependencies and defines what your app can do.
  • data/ contains the database entities, DAO interfaces, and repository implementations. It handles how data is stored and retrieved.
  • presentation/ contains your Compose screens and ViewModels. It handles how data is displayed to the user.
  • di/ contains Hilt modules that wire all the layers together.
  • Verify your folder structure by expanding each package in the Project panel. You should see all nine packages nested under com.example.taskmanager.

Packages not appearing?

If you do not see the packages, make sure you are creating them under the main source set (app/src/main/java/com/example/taskmanager), not the test or androidTest source sets. Also try switching between Android and Project view to confirm they exist on disk.

Help me find where to create packages in Android Studio.

Your project foundation is ready. Next up, you will build the domain layer where you define what a Task is and what operations your app can perform on tasks.

Build the Domain Layer

Your project structure is ready and all dependencies are configured. Now it's time to define what your app works with before you decide how it stores or displays data.

In Clean Architecture, the domain layer is the innermost core. It defines your data models, the operations your app supports, and the contracts other layers must follow. It has zero dependencies on Android frameworks, databases, or UI.

In this step, get ready to:
  • Create the Task model and Priority enum.
  • Define the TaskRepository interface.
  • Build Use Case classes for each CRUD operation.
Create the Task model

Every task in your app needs a shape. You need to define what fields a task has (title, priority, completion status) so that every other layer agrees on the same structure.

  • In Android Studio, right-click the domain/model package in the **Project** panel.
  • Select **New**, then **Kotlin Class/File**.
  • Choose **File**, name it Task, and press **Enter**.
  • Replace any generated content with the following code:
package com.example.taskmanager.domain.model

// Priority levels a task can have
enum class Priority {
    LOW, MEDIUM, HIGH
}

// Core task model used across the entire app
data class Task(
    val id: Int = 0,
    val title: String,
    val description: String = "",
    val priority: Priority = Priority.MEDIUM,
    val isCompleted: Boolean = false,
    val createdAt: Long = System.currentTimeMillis()
)

What does this code do?

You defined two things in one file:

  • Priority is an enum with three levels: LOW, MEDIUM, HIGH. Enums restrict values to a fixed set, so you can never accidentally assign an invalid priority.
  • Task is a data class. Kotlin data classes automatically generate equals(), hashCode(), and copy() methods for you.
  • Default values (like id = 0 and priority = Priority.MEDIUM) mean you only need to supply a title when creating a new task.
  • Save Task.kt.
  • Confirm there are no red underlines in the editor. The file should show no errors since this is pure Kotlin with no external imports.

Seeing a red underline on the package name?

Make sure the file is inside the correct package folder. The package declaration com.example.taskmanager.domain.model must match the actual folder path in your project.

If you created the file in the wrong location, delete it and right-click the correct domain/model package to create it again.

Help me fix a package name mismatch in my Task.kt file.

✔️ Awesome, I've got everything!

Great. Make sure you have saved Task.kt before moving on.

ⓧ I'd like to double check the full code

package com.example.taskmanager.domain.model

enum class Priority {
    LOW, MEDIUM, HIGH
}

data class Task(
    val id: Int = 0,
    val title: String,
    val description: String = "",
    val priority: Priority = Priority.MEDIUM,
    val isCompleted: Boolean = false,
    val createdAt: Long = System.currentTimeMillis()
)
Define the repository interface

Your domain layer needs to declare what data operations are available without specifying how they work. An interface acts as a contract: the domain says "I need these operations" and a separate data layer will provide the actual implementation later.

  • Right-click the domain/repository package in the **Project** panel.
  • Select **New**, then **Kotlin Class/File**.
  • Choose **File**, name it TaskRepository, and press **Enter**.
  • Replace any generated content with the following code:
package com.example.taskmanager.domain.repository

import com.example.taskmanager.domain.model.Task
import kotlinx.coroutines.flow.Flow

// Contract that the data layer must fulfill
interface TaskRepository {
    // Returns a reactive stream of all tasks
    fun getAllTasks(): Flow<List<Task>>
    // Single-task lookup by ID
    suspend fun getTaskById(id: Int): Task?
    // Write operations - suspend because they run on a background thread
    suspend fun insertTask(task: Task)
    suspend fun updateTask(task: Task)
    suspend fun deleteTask(task: Task)
}

What does this code do?

  • getAllTasks() returns a Flow. A Flow is a reactive stream from Kotlin Coroutines that automatically emits new data whenever the database changes. Your UI will update in real time without manual refresh calls.
  • The suspend keyword marks functions that perform long-running work (like database writes). They can only be called from a coroutine, which keeps the main thread free for UI updates.
  • This interface imports your Task model but knows nothing about Room, SQL, or any database. That is separation of concerns in action.
  • Save TaskRepository.kt.
  • Confirm no red underlines appear. The Flow import resolves from the coroutines dependency you added in Step 1.

Red underline on Flow or the Task import?

If Flow is unresolved, your Room KTX dependency may be missing. Switch back to your app-level build.gradle.kts and confirm the Room runtime dependency is present, then click **Sync Now**.

If the Task import is red, make sure you saved Task.kt in the previous substep and that its package matches com.example.taskmanager.domain.model.

Help me resolve an unresolved import in TaskRepository.kt.

✔️ Awesome, I've got everything!

Great. Make sure you have saved TaskRepository.kt before moving on.

ⓧ I'd like to double check the full code

package com.example.taskmanager.domain.repository

import com.example.taskmanager.domain.model.Task
import kotlinx.coroutines.flow.Flow

interface TaskRepository {
    fun getAllTasks(): Flow<List<Task>>
    suspend fun getTaskById(id: Int): Task?
    suspend fun insertTask(task: Task)
    suspend fun updateTask(task: Task)
    suspend fun deleteTask(task: Task)
}
Create the use case classes

Use cases represent individual actions your app can perform. Each class does exactly one thing: get all tasks, add a task, update a task, or delete a task. This keeps your ViewModel thin and makes each operation independently testable.

Why separate classes for each operation?

You could call the repository directly from a ViewModel. However, use cases add a layer where you can place business logic (validation, combining data from multiple sources) without cluttering the ViewModel.

Each use case overrides the invoke() operator so you can call it like a function. This is a common Kotlin convention in Clean Architecture.

  • Right-click the domain/usecase package in the **Project** panel.
  • Select **New**, then **Kotlin Class/File**.
  • Choose **File**, name it GetAllTasksUseCase, and press **Enter**.
  • Replace any generated content with the following code:
package com.example.taskmanager.domain.usecase

import com.example.taskmanager.domain.model.Task
import com.example.taskmanager.domain.repository.TaskRepository
import kotlinx.coroutines.flow.Flow
import javax.inject.Inject

// Use case for retrieving all tasks as a reactive stream
class GetAllTasksUseCase @Inject constructor(
    private val repository: TaskRepository
) {
    // Returns a Flow so the UI updates automatically when data changes
    operator fun invoke(): Flow<List<Task>> {
        return repository.getAllTasks()
    }
}

What does this code do?

  • @Inject constructor tells Hilt how to create this class. Hilt will automatically supply the TaskRepository when the use case is needed.
  • operator fun invoke() lets you call this class like a function: getAllTasksUseCase() instead of getAllTasksUseCase.invoke().
  • This use case is NOT suspend because it returns a Flow. The Flow itself is cold and only starts emitting when someone collects it.
  • Save GetAllTasksUseCase.kt.

The remaining three use cases follow the same pattern but use suspend because they perform write operations.

  • Create a new file in domain/usecase named AddTaskUseCase and paste the following code:
package com.example.taskmanager.domain.usecase

import com.example.taskmanager.domain.model.Task
import com.example.taskmanager.domain.repository.TaskRepository
import javax.inject.Inject

// Use case for adding a new task to the database
class AddTaskUseCase @Inject constructor(
    private val repository: TaskRepository
) {
    // Suspend because inserting is a database write operation
    suspend operator fun invoke(task: Task) {
        repository.insertTask(task)
    }
}

What's different here?

This use case uses suspend operator fun invoke instead of a plain operator fun invoke. The suspend keyword is required because database writes are long-running and must not block the main thread.

  • Save AddTaskUseCase.kt.
  • Create another new file in domain/usecase named UpdateTaskUseCase and paste the following code:
package com.example.taskmanager.domain.usecase

import com.example.taskmanager.domain.model.Task
import com.example.taskmanager.domain.repository.TaskRepository
import javax.inject.Inject

// Use case for updating an existing task
class UpdateTaskUseCase @Inject constructor(
    private val repository: TaskRepository
) {
    suspend operator fun invoke(task: Task) {
        repository.updateTask(task)
    }
}
  • Save UpdateTaskUseCase.kt.
  • Create one final file in domain/usecase named DeleteTaskUseCase and paste the following code:
package com.example.taskmanager.domain.usecase

import com.example.taskmanager.domain.model.Task
import com.example.taskmanager.domain.repository.TaskRepository
import javax.inject.Inject

// Use case for removing a task from the database
class DeleteTaskUseCase @Inject constructor(
    private val repository: TaskRepository
) {
    suspend operator fun invoke(task: Task) {
        repository.deleteTask(task)
    }
}
  • Save DeleteTaskUseCase.kt.
  • Build your project by clicking **Build** in the top menu, then **Make Project** (or press **Ctrl+F9** on Windows, **Cmd+F9** on macOS).
  • You should see "Build Successful" in the **Build** output panel at the bottom of Android Studio. This confirms all six domain files compile without errors.

Build failing with unresolved references?

  • If javax.inject.Inject is unresolved, your Hilt dependency is missing. Open your app-level build.gradle.kts and confirm you have the Hilt Android implementation, then click **Sync Now**.
  • If TaskRepository is unresolved in the use cases, check that your TaskRepository.kt file is saved and lives in the domain/repository package.
  • If you see "Duplicate class" errors, make sure each file has the correct package declaration matching its folder location.

Help me fix build errors in my domain layer use case files.

✔️ Awesome, I've got everything!

Great. You should have four files in your domain/usecase package and a passing build.

ⓧ I'd like to double check the full code

package com.example.taskmanager.domain.usecase

import com.example.taskmanager.domain.model.Task
import com.example.taskmanager.domain.repository.TaskRepository
import kotlinx.coroutines.flow.Flow
import javax.inject.Inject

class GetAllTasksUseCase @Inject constructor(
    private val repository: TaskRepository
) {
    operator fun invoke(): Flow<List<Task>> {
        return repository.getAllTasks()
    }
}
package com.example.taskmanager.domain.usecase

import com.example.taskmanager.domain.model.Task
import com.example.taskmanager.domain.repository.TaskRepository
import javax.inject.Inject

class AddTaskUseCase @Inject constructor(
    private val repository: TaskRepository
) {
    suspend operator fun invoke(task: Task) {
        repository.insertTask(task)
    }
}
package com.example.taskmanager.domain.usecase

import com.example.taskmanager.domain.model.Task
import com.example.taskmanager.domain.repository.TaskRepository
import javax.inject.Inject

class UpdateTaskUseCase @Inject constructor(
    private val repository: TaskRepository
) {
    suspend operator fun invoke(task: Task) {
        repository.updateTask(task)
    }
}
package com.example.taskmanager.domain.usecase

import com.example.taskmanager.domain.model.Task
import com.example.taskmanager.domain.repository.TaskRepository
import javax.inject.Inject

class DeleteTaskUseCase @Inject constructor(
    private val repository: TaskRepository
) {
    suspend operator fun invoke(task: Task) {
        repository.deleteTask(task)
    }
}

Your domain layer is complete. You have a solid foundation: a Task model, a repository contract, and four focused use cases. Next up, you will build the data layer with Room to give these contracts a real database implementation.

Build the Data Layer with Room

Your domain layer defines what your app can do with tasks. Now you need a layer that handles how those tasks get saved to and retrieved from a real database on the device.

Room is Android's persistence library that provides an abstraction layer over SQLite. It handles all the complex SQL boilerplate so you can work with Kotlin objects directly.

In this step, get ready to:
  • Create the Room entity and DAO for database operations.
  • Build the Room database class.
  • Implement the repository that connects your domain layer to the database.
Create the TaskEntity and TaskDao

Room needs two things to work with your database: an Entity that defines what a database row looks like, and a DAO (Data Access Object) that defines the queries you can run.

  • Create a new file called TaskEntity.kt inside the data/local/ package.
  • Add the following code to define the database table structure:
package com.example.taskmanager.data.local

import androidx.room.Entity
import androidx.room.PrimaryKey

// Defines the "tasks" table in the SQLite database
@Entity(tableName = "tasks")
data class TaskEntity(
    // Auto-generates unique IDs for each new task
    @PrimaryKey(autoGenerate = true)
    val id: Int = 0,
    val title: String,
    val description: String,
    // Stored as a String ("LOW", "MEDIUM", "HIGH") in the database
    val priority: String,
    val isCompleted: Boolean,
    val createdAt: Long
)

What does this code do?

@Entity(tableName = "tasks") tells Room to create a database table called "tasks" with columns matching each property.

@PrimaryKey(autoGenerate = true) makes Room automatically assign a unique integer ID to every new row. Notice that priority is stored as a String here, not the Priority enum. Room can only store primitive types, so you will convert between the two later.

Why a separate TaskEntity instead of reusing Task?

In Clean Architecture, the domain model (Task) should never depend on framework details like Room annotations. Keeping a separate TaskEntity means you can swap out Room for another database library without touching your domain layer.

  • Save TaskEntity.kt.

Now you need the DAO to define how your app reads from and writes to the tasks table.

  • Create a new file called TaskDao.kt inside the same data/local/ package.
  • Add the following code to define your database queries:
package com.example.taskmanager.data.local

import androidx.room.Dao
import androidx.room.Delete
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import androidx.room.Update
import kotlinx.coroutines.flow.Flow

@Dao
interface TaskDao {
    // Returns all tasks sorted by newest first, as a reactive Flow
    @Query("SELECT * FROM tasks ORDER BY createdAt DESC")
    fun getAllTasks(): Flow<List<TaskEntity>>

    // Finds a single task by its ID
    @Query("SELECT * FROM tasks WHERE id = :id")
    suspend fun getTaskById(id: Int): TaskEntity?

    // Inserts a task, replacing it if a conflict occurs
    @Insert(onConflict = OnConflictStrategy.REPLACE)
    suspend fun insertTask(task: TaskEntity)

    @Update
    suspend fun updateTask(task: TaskEntity)

    @Delete
    suspend fun deleteTask(task: TaskEntity)
}

What does this code do?

@Dao marks this interface as a Data Access Object. Room generates the implementation for you at compile time using KSP.

getAllTasks() returns a Flow<List<TaskEntity>> instead of a plain list. This means your UI automatically updates whenever the database changes. The other methods use suspend because they perform one-shot database writes that should run off the main thread.

  • Save TaskDao.kt.
  • Confirm that Android Studio shows no red underlines in either TaskEntity.kt or TaskDao.kt. Both files should resolve their Room imports without errors.

Seeing unresolved references to Room annotations?

  • Make sure your build.gradle.kts (app-level) includes the Room dependencies from Step 1. Try clicking **Sync Now** in the notification bar at the top of Android Studio.
  • Check that your package declaration matches com.example.taskmanager.data.local and that the files are in the correct folder.

Help me fix Room annotation import errors in my TaskDao.

Create the AppDatabase class

Room needs a central database class that ties your entities and DAOs together. This class tells Room which tables exist and provides access to each DAO.

  • Create a new file called AppDatabase.kt inside the data/local/ package.
  • Add the following code:
package com.example.taskmanager.data.local

import androidx.room.Database
import androidx.room.RoomDatabase

// Registers TaskEntity as a table and sets the database version
@Database(entities = [TaskEntity::class], version = 1, exportSchema = false)
abstract class AppDatabase : RoomDatabase() {
    // Room generates the TaskDao implementation at compile time
    abstract fun taskDao(): TaskDao
}

What does this code do?

@Database registers your entity classes with Room. The entities array lists every table, version = 1 tracks schema changes, and exportSchema = false disables schema export files (not needed for this project).

The abstract taskDao() method tells Room to generate the DAO implementation. You will never write SQL yourself. Room handles it all behind the scenes.

  • Save AppDatabase.kt.
  • Confirm Android Studio shows no red underlines. The TaskEntity::class reference should resolve to the file you just created.

TaskEntity::class showing as unresolved?

  • Both AppDatabase.kt and TaskEntity.kt must share the same package: com.example.taskmanager.data.local. Double-check that the package declarations at the top of each file match.
  • If you still see errors after saving, click **File** then **Invalidate Caches / Restart** to force Android Studio to re-index.

Help me fix unresolved TaskEntity in my AppDatabase.

Create the TaskRepositoryImpl

Your domain layer defined a TaskRepository interface. Now you need a concrete implementation that uses your Room DAO to actually perform the database operations.

This is where the mapping between TaskEntity (database row) and Task (domain model) happens.

  • Create a new file called TaskRepositoryImpl.kt inside the data/repository/ package.
  • Add the following code to implement the repository:
package com.example.taskmanager.data.repository

import com.example.taskmanager.data.local.TaskDao
import com.example.taskmanager.data.local.TaskEntity
import com.example.taskmanager.domain.model.Priority
import com.example.taskmanager.domain.model.Task
import com.example.taskmanager.domain.repository.TaskRepository
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import javax.inject.Inject

class TaskRepositoryImpl @Inject constructor(
    private val taskDao: TaskDao
) : TaskRepository {

    // Retrieves all tasks from the database and converts them to domain models
    override fun getAllTasks(): Flow<List<Task>> {
        return taskDao.getAllTasks().map { entities ->
            entities.map { it.toDomain() }
        }
    }

    override suspend fun getTaskById(id: Int): Task? {
        return taskDao.getTaskById(id)?.toDomain()
    }

    override suspend fun insertTask(task: Task) {
        taskDao.insertTask(task.toEntity())
    }

    override suspend fun updateTask(task: Task) {
        taskDao.updateTask(task.toEntity())
    }

    override suspend fun deleteTask(task: Task) {
        taskDao.deleteTask(task.toEntity())
    }

    // Converts a database entity to a domain model
    private fun TaskEntity.toDomain(): Task {
        return Task(
            id = id,
            title = title,
            description = description,
            priority = Priority.valueOf(priority),
            isCompleted = isCompleted,
            createdAt = createdAt
        )
    }

    // Converts a domain model to a database entity
    private fun Task.toEntity(): TaskEntity {
        return TaskEntity(
            id = id,
            title = title,
            description = description,
            priority = priority.name,
            isCompleted = isCompleted,
            createdAt = createdAt
        )
    }
}

What does this code do?

TaskRepositoryImpl is the bridge between your domain and data layers. It receives the TaskDao through its constructor and uses it to perform all database operations.

The toDomain() function converts a database row back into your app's Task model (including converting the String back to a Priority enum). The toEntity() function does the reverse. This keeps database concerns completely out of your domain layer.

  • Save TaskRepositoryImpl.kt.
  • Confirm that Android Studio shows no red underlines. The TaskRepository interface methods should all show green override indicators in the gutter.

Seeing override errors or unresolved references?

  • Make sure the method signatures in TaskRepositoryImpl exactly match the interface in domain/repository/TaskRepository.kt. Return types, parameter types, and suspend modifiers all need to match.
  • Verify that TaskDao and TaskEntity imports resolve. If Android Studio suggests adding an import, accept it.

Help me fix my TaskRepositoryImpl override errors.

✔️ Awesome, I've got everything!

Great. Double check you have saved all four files before moving on.

ⓧ I'd like to double check the full code

Here are all four data layer files for comparison.

package com.example.taskmanager.data.local

import androidx.room.Entity
import androidx.room.PrimaryKey

@Entity(tableName = "tasks")
data class TaskEntity(
    @PrimaryKey(autoGenerate = true)
    val id: Int = 0,
    val title: String,
    val description: String,
    val priority: String,
    val isCompleted: Boolean,
    val createdAt: Long
)
package com.example.taskmanager.data.local

import androidx.room.Dao
import androidx.room.Delete
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import androidx.room.Update
import kotlinx.coroutines.flow.Flow

@Dao
interface TaskDao {
    @Query("SELECT * FROM tasks ORDER BY createdAt DESC")
    fun getAllTasks(): Flow<List<TaskEntity>>

    @Query("SELECT * FROM tasks WHERE id = :id")
    suspend fun getTaskById(id: Int): TaskEntity?

    @Insert(onConflict = OnConflictStrategy.REPLACE)
    suspend fun insertTask(task: TaskEntity)

    @Update
    suspend fun updateTask(task: TaskEntity)

    @Delete
    suspend fun deleteTask(task: TaskEntity)
}
package com.example.taskmanager.data.local

import androidx.room.Database
import androidx.room.RoomDatabase

@Database(entities = [TaskEntity::class], version = 1, exportSchema = false)
abstract class AppDatabase : RoomDatabase() {
    abstract fun taskDao(): TaskDao
}
package com.example.taskmanager.data.repository

import com.example.taskmanager.data.local.TaskDao
import com.example.taskmanager.data.local.TaskEntity
import com.example.taskmanager.domain.model.Priority
import com.example.taskmanager.domain.model.Task
import com.example.taskmanager.domain.repository.TaskRepository
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import javax.inject.Inject

class TaskRepositoryImpl @Inject constructor(
    private val taskDao: TaskDao
) : TaskRepository {

    override fun getAllTasks(): Flow<List<Task>> {
        return taskDao.getAllTasks().map { entities ->
            entities.map { it.toDomain() }
        }
    }

    override suspend fun getTaskById(id: Int): Task? {
        return taskDao.getTaskById(id)?.toDomain()
    }

    override suspend fun insertTask(task: Task) {
        taskDao.insertTask(task.toEntity())
    }

    override suspend fun updateTask(task: Task) {
        taskDao.updateTask(task.toEntity())
    }

    override suspend fun deleteTask(task: Task) {
        taskDao.deleteTask(task.toEntity())
    }

    private fun TaskEntity.toDomain(): Task {
        return Task(
            id = id,
            title = title,
            description = description,
            priority = Priority.valueOf(priority),
            isCompleted = isCompleted,
            createdAt = createdAt
        )
    }

    private fun Task.toEntity(): TaskEntity {
        return TaskEntity(
            id = id,
            title = title,
            description = description,
            priority = priority.name,
            isCompleted = isCompleted,
            createdAt = createdAt
        )
    }
}

Your data layer is complete. Room knows how to store tasks, and TaskRepositoryImpl bridges the gap between your database and domain layer. Next up, you will wire everything together with Hilt dependency injection so your app knows which implementations to use at runtime.

Set Up Hilt Dependency Injection

Your domain and data layers are built. You have a TaskRepository interface, a TaskRepositoryImpl, and four use case classes. But nothing is wiring them together yet.

In this step, you will configure Hilt to automatically provide dependencies throughout the app. Hilt generates the glue code that connects your Room database to your repository, and your repository to your use cases.

In this step, get ready to:
  • Create the TaskManagerApplication class and register it in the manifest.
  • Create DatabaseModule to provide the Room database and DAO.
  • Create RepositoryModule to bind the repository interface to its implementation.
Create the Hilt application class

Every app that uses Hilt needs a custom Application class annotated with @HiltAndroidApp. This triggers Hilt's code generation and creates the app-level dependency container.

  • In Android Studio, right-click the com.example.taskmanager package (the root package, same level as MainActivity.kt).
  • Select New then Kotlin Class/File.
  • Name it TaskManagerApplication and select Class.
  • Replace the file contents with this code:
package com.example.taskmanager

import android.app.Application
import dagger.hilt.android.HiltAndroidApp

// Triggers Hilt code generation and serves as the app-level dependency container
@HiltAndroidApp
class TaskManagerApplication : Application()

What does this code do?

The @HiltAndroidApp annotation tells Hilt to generate a base class for your application. This generated class acts as the parent component for all other Hilt components in the app.

Every ViewModel, Activity, or other Android class that needs injection will ultimately trace back to this root container.

Now you need to tell Android to use this custom Application class instead of the default one. You do this in AndroidManifest.xml.

  • Open AndroidManifest.xml (located in app/src/main/).
  • Find the <application opening tag.
  • Add the android:name attribute pointing to your new class:
<application
    android:name=".TaskManagerApplication"
    ...>

Why the leading dot?

The dot before TaskManagerApplication is shorthand. Android prepends your app's namespace (com.example.taskmanager) to resolve the full class name automatically.

  • Save both TaskManagerApplication.kt and AndroidManifest.xml.
  • Check that Android Studio shows no red underlines in either file.

Seeing an unresolved reference error?

Make sure TaskManagerApplication.kt is inside the com.example.taskmanager root package, not inside a sub-package like data/ or domain/. The manifest expects it at the package root.

Also confirm the Hilt Gradle plugin is applied in your project-level build.gradle.kts. Without it, the @HiltAndroidApp annotation won't resolve.

Help me fix my @HiltAndroidApp unresolved reference error.

Create the database module

Hilt modules tell the DI framework how to create instances of types that it cannot construct on its own. The Room database requires a builder call, so you need a module with @Provides functions.

  • Right-click the com.example.taskmanager package and create a new package called di.
  • Inside di/, create a new Kotlin file named DatabaseModule.
  • Paste the following code:
package com.example.taskmanager.di

import android.content.Context
import androidx.room.Room
import com.example.taskmanager.data.local.AppDatabase
import com.example.taskmanager.data.local.TaskDao
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton

// Tells Hilt how to create the Room database and DAO instances
@Module
@InstallIn(SingletonComponent::class)
object DatabaseModule {

    // Builds the Room database with name "task_database"
    @Provides
    @Singleton
    fun provideDatabase(@ApplicationContext context: Context): AppDatabase {
        return Room.databaseBuilder(
            context,
            AppDatabase::class.java,
            "task_database"
        ).build()
    }

    // Extracts the DAO from the database instance
    @Provides
    @Singleton
    fun provideTaskDao(database: AppDatabase): TaskDao {
        return database.taskDao()
    }
}

What does this code do?

  • @Module marks this as a Hilt module containing dependency definitions.
  • @InstallIn(SingletonComponent::class) means these dependencies live for the entire app lifecycle. One database instance, shared everywhere.
  • @Provides tells Hilt to call these functions whenever it needs an AppDatabase or TaskDao.
  • @Singleton ensures Hilt creates only one instance and reuses it across the app.
  • Save DatabaseModule.kt.
  • Verify that all imports resolve without errors. If AppDatabase or TaskDao show red, confirm they exist in data/local/.

Unresolved reference to AppDatabase?

Double check the package declaration at the top of your AppDatabase.kt file. It should be package com.example.taskmanager.data.local. A mismatched package name will prevent the import from resolving.

Help me fix unresolved imports in my DatabaseModule.

Create the repository module

The database module provides concrete instances. The repository module handles a different pattern: binding an interface to its implementation. This lets Hilt inject TaskRepository (the interface) anywhere, while automatically providing TaskRepositoryImpl under the hood.

  • Inside the di/ package, create a new Kotlin file named RepositoryModule.
  • Paste the following code:
package com.example.taskmanager.di

import com.example.taskmanager.data.repository.TaskRepositoryImpl
import com.example.taskmanager.domain.repository.TaskRepository
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton

// Binds the TaskRepository interface to its concrete implementation
@Module
@InstallIn(SingletonComponent::class)
abstract class RepositoryModule {

    // Tells Hilt: when something asks for TaskRepository, give it TaskRepositoryImpl
    @Binds
    @Singleton
    abstract fun bindTaskRepository(
        taskRepositoryImpl: TaskRepositoryImpl
    ): TaskRepository
}

What does this code do?

@Binds is more efficient than @Provides for interface-to-implementation mappings. It tells Hilt that whenever any class requests a TaskRepository, it should supply a TaskRepositoryImpl instance.

Because TaskRepositoryImpl already has @Inject constructor in its class definition, Hilt knows how to create it. The TaskDao it needs comes from the DatabaseModule you just created.

Why @Binds instead of @Provides?

Use @Provides when you need to call a builder or constructor manually (like Room.databaseBuilder). Use @Binds when you just need to map an interface to a class that Hilt can already construct. It generates less code and is the recommended approach for interface bindings.

  • Save RepositoryModule.kt.
  • Click Build then Rebuild Project in the top menu to trigger Hilt's code generation.
  • Check the Build output panel at the bottom. You should see a successful build with no errors.

Build failing with Hilt errors?

If you see an error mentioning a missing binding, check that TaskRepositoryImpl has @Inject constructor in its class definition. Without @Inject, Hilt cannot create the implementation.

Also confirm that you applied the KSP plugin (com.google.devtools.ksp) in your app-level build.gradle.kts. Hilt needs KSP to process annotations and generate code.

Help me fix my Hilt build errors.

✔️ Awesome, I've got everything!

Great. Double check you have saved all three new files.

ⓧ I'd like to double check the full code

Here is the complete code for each new file in this step.

package com.example.taskmanager

import android.app.Application
import dagger.hilt.android.HiltAndroidApp

@HiltAndroidApp
class TaskManagerApplication : Application()
package com.example.taskmanager.di

import android.content.Context
import androidx.room.Room
import com.example.taskmanager.data.local.AppDatabase
import com.example.taskmanager.data.local.TaskDao
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton

@Module
@InstallIn(SingletonComponent::class)
object DatabaseModule {

    @Provides
    @Singleton
    fun provideDatabase(@ApplicationContext context: Context): AppDatabase {
        return Room.databaseBuilder(
            context,
            AppDatabase::class.java,
            "task_database"
        ).build()
    }

    @Provides
    @Singleton
    fun provideTaskDao(database: AppDatabase): TaskDao {
        return database.taskDao()
    }
}
package com.example.taskmanager.di

import com.example.taskmanager.data.repository.TaskRepositoryImpl
import com.example.taskmanager.domain.repository.TaskRepository
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton

@Module
@InstallIn(SingletonComponent::class)
abstract class RepositoryModule {

    @Binds
    @Singleton
    abstract fun bindTaskRepository(
        taskRepositoryImpl: TaskRepositoryImpl
    ): TaskRepository
}

Your Dependency Injection layer is wired up. Hilt now knows how to create the database, the DAO, the repository, and all four use cases. Next up, you will build the Task List screen and see these layers come together in a live UI.

Build the Task List Screen with ViewModel

Your architecture's foundation is solid. The domain layer defines the rules, and the data layer handles persistence. Now you need a way for the user to actually see and interact with their tasks.

In this step, you will build the presentation layer for the task list. This means creating a ViewModel that exposes reactive state, and Jetpack Compose screens that render that state as a scrollable list of task cards.

In this step, get ready to:
  • Create a TaskListViewModel that exposes tasks as a reactive StateFlow.
  • Build a TaskItem composable card with priority colors, checkbox, and delete.
  • Build the TaskListScreen with a FAB and wire ViewModel events.
Create TaskListViewModel with StateFlow

The ViewModel sits between your UI and domain layer. It collects the Flow of tasks from GetAllTasksUseCase and converts it into a StateFlow that Compose can observe.

  • Create a new file called TaskListViewModel.kt inside presentation/tasklist/.
  • Add the following code:
package com.example.taskmanager.presentation.tasklist

import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.example.taskmanager.domain.model.Task
import com.example.taskmanager.domain.usecase.DeleteTaskUseCase
import com.example.taskmanager.domain.usecase.GetAllTasksUseCase
import com.example.taskmanager.domain.usecase.UpdateTaskUseCase
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import javax.inject.Inject

@HiltViewModel
class TaskListViewModel @Inject constructor(
    getAllTasksUseCase: GetAllTasksUseCase,
    private val updateTaskUseCase: UpdateTaskUseCase,
    private val deleteTaskUseCase: DeleteTaskUseCase
) : ViewModel() {

    // Convert the Flow from the use case into a StateFlow for Compose to observe
    val tasks: StateFlow<List<Task>> = getAllTasksUseCase()
        .stateIn(
            scope = viewModelScope,
            started = SharingStarted.WhileSubscribed(5000),
            initialValue = emptyList()
        )

    // Toggle a task's completion status and persist the change
    fun toggleTaskCompletion(task: Task) {
        viewModelScope.launch {
            updateTaskUseCase(task.copy(isCompleted = !task.isCompleted))
        }
    }

    // Delete a task from the database
    fun deleteTask(task: Task) {
        viewModelScope.launch {
            deleteTaskUseCase(task)
        }
    }
}

What does this code do?

The @HiltViewModel annotation tells Hilt to handle creating this ViewModel and injecting its dependencies automatically.

The stateIn operator converts the cold Flow from the use case into a hot StateFlow. The WhileSubscribed(5000) strategy keeps the upstream active for 5 seconds after the last subscriber disappears, avoiding unnecessary database re-queries during configuration changes like screen rotations.

  • Save TaskListViewModel.kt.
  • Confirm the file has no red underlines in Android Studio. The imports should all resolve against your existing domain layer classes.

Seeing unresolved references?

Make sure the file is inside com.example.taskmanager.presentation.tasklist and that the package declaration matches.

If GetAllTasksUseCase or DeleteTaskUseCase is unresolved, confirm those files exist in domain/usecase/ from the previous step.

help me fix unresolved references in my TaskListViewModel

Create the TaskItem composable card

Each task in the list needs its own visual representation. The TaskItem composable renders a single card showing the task's priority as a colored dot, its title with strikethrough when completed, and action controls.

  • Create a new file called TaskListScreen.kt inside presentation/tasklist/.
  • Add the package declaration, all imports, and the TaskItem composable by pasting this code:
package com.example.taskmanager.presentation.tasklist

import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Checkbox
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import com.example.taskmanager.domain.model.Priority
import com.example.taskmanager.domain.model.Task

@Composable
fun TaskItem(
    task: Task,
    onToggleComplete: () -> Unit,
    onDelete: () -> Unit
) {
    Card(
        modifier = Modifier.fillMaxWidth(),
        elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)
    ) {
        Row(
            modifier = Modifier
                .fillMaxWidth()
                .padding(12.dp),
            verticalAlignment = Alignment.CenterVertically
        ) {
            // Checkbox to toggle task completion
            Checkbox(
                checked = task.isCompleted,
                onCheckedChange = { onToggleComplete() }
            )
            Spacer(modifier = Modifier.width(8.dp))
            // Priority color indicator dot
            Surface(
                modifier = Modifier.size(12.dp),
                shape = CircleShape,
                color = when (task.priority) {
                    Priority.HIGH -> Color.Red
                    Priority.MEDIUM -> Color(0xFFFFA000)
                    Priority.LOW -> Color.Green
                }
            ) {}
            Spacer(modifier = Modifier.width(12.dp))
            // Task title and description
            Column(modifier = Modifier.weight(1f)) {
                Text(
                    text = task.title,
                    style = MaterialTheme.typography.titleMedium,
                    textDecoration = if (task.isCompleted) TextDecoration.LineThrough else null,
                    maxLines = 1,
                    overflow = TextOverflow.Ellipsis
                )
                if (task.description.isNotBlank()) {
                    Text(
                        text = task.description,
                        style = MaterialTheme.typography.bodySmall,
                        maxLines = 1,
                        overflow = TextOverflow.Ellipsis
                    )
                }
            }
            // Delete button
            IconButton(onClick = onDelete) {
                Icon(
                    Icons.Default.Delete,
                    contentDescription = "Delete Task",
                    tint = MaterialTheme.colorScheme.error
                )
            }
        }
    }
}

What does this code do?

  • The Card wraps each task in a Material Design 3 elevated container.
  • A Surface with CircleShape creates a small colored dot. Red means HIGH, amber means MEDIUM, green means LOW.
  • The TextDecoration.LineThrough visually strikes through completed task titles so users instantly see what is done.
  • Lambda callbacks (onToggleComplete, onDelete) keep this composable stateless. The parent decides what happens when a user taps.
  • Save TaskListScreen.kt.
  • You will not see a visual change yet. The next chunk builds the screen that uses this card.
Add the TaskListScreen composable with FAB and event wiring

The main screen composable pulls everything together. It observes the ViewModel's StateFlow, shows an empty state when there are no tasks, and renders a scrollable list of TaskItem cards when tasks exist.

  • In TaskListScreen.kt, add the following TaskListScreen composable above the TaskItem function:
@Composable
fun TaskListScreen(
    onAddTaskClick: () -> Unit,
    viewModel: TaskListViewModel = hiltViewModel()
) {
    val tasks by viewModel.tasks.collectAsState()

    Scaffold(
        floatingActionButton = {
            // FAB to navigate to the add task screen
            FloatingActionButton(onClick = onAddTaskClick) {
                Icon(Icons.Default.Add, contentDescription = "Add Task")
            }
        }
    ) { paddingValues ->
        if (tasks.isEmpty()) {
            // Empty state when no tasks exist
            Column(
                modifier = Modifier
                    .fillMaxSize()
                    .padding(paddingValues),
                horizontalAlignment = Alignment.CenterHorizontally,
                verticalArrangement = Arrangement.Center
            ) {
                Text(
                    text = "No tasks yet!",
                    style = MaterialTheme.typography.headlineSmall
                )
                Spacer(modifier = Modifier.height(8.dp))
                Text(
                    text = "Tap + to add your first task",
                    style = MaterialTheme.typography.bodyMedium
                )
            }
        } else {
            // Scrollable list of task cards
            LazyColumn(
                modifier = Modifier
                    .fillMaxSize()
                    .padding(paddingValues)
                    .padding(horizontal = 16.dp),
                verticalArrangement = Arrangement.spacedBy(8.dp)
            ) {
                item { Spacer(modifier = Modifier.height(8.dp)) }
                items(tasks, key = { it.id }) { task ->
                    TaskItem(
                        task = task,
                        onToggleComplete = { viewModel.toggleTaskCompletion(task) },
                        onDelete = { viewModel.deleteTask(task) }
                    )
                }
                item { Spacer(modifier = Modifier.height(8.dp)) }
            }
        }
    }
}

What does this code do?

  • The hiltViewModel() call asks Hilt to create and inject the ViewModel for you. No manual factory needed.
  • The collectAsState() extension converts the StateFlow into Compose State. Whenever the task list changes in the database, the UI recomposes automatically.
  • The FloatingActionButton calls onAddTaskClick which will eventually navigate to the add task screen.
  • Each TaskItem receives lambdas that call viewModel.toggleTaskCompletion(task) and viewModel.deleteTask(task). This wires user interactions directly through the ViewModel to the use cases.

Why use a Scaffold?

The Scaffold composable handles the layout structure for Material Design screens. It manages content padding around system bars and positions the FAB correctly in the bottom-right corner without manual offset calculations.

  • Save TaskListScreen.kt.
  • Click Build > Make Project in the top menu. The project should compile with no errors.

Build failing with unresolved hiltViewModel?

Make sure your app-level build.gradle.kts includes the androidx.hilt:hilt-lifecycle-viewmodel-compose dependency. This provides the hiltViewModel() function for Compose.

Also verify that the import androidx.hilt.navigation.compose.hiltViewModel line is present at the top of your file.

help me fix hiltViewModel not resolving in my Compose screen

✔️ Awesome, I've got everything!

Great. Double check you have saved both files.

ⓧ I'd like to double check the full code

Here is the full TaskListViewModel.kt:

package com.example.taskmanager.presentation.tasklist

import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.example.taskmanager.domain.model.Task
import com.example.taskmanager.domain.usecase.DeleteTaskUseCase
import com.example.taskmanager.domain.usecase.GetAllTasksUseCase
import com.example.taskmanager.domain.usecase.UpdateTaskUseCase
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import javax.inject.Inject

@HiltViewModel
class TaskListViewModel @Inject constructor(
    getAllTasksUseCase: GetAllTasksUseCase,
    private val updateTaskUseCase: UpdateTaskUseCase,
    private val deleteTaskUseCase: DeleteTaskUseCase
) : ViewModel() {

    val tasks: StateFlow<List<Task>> = getAllTasksUseCase()
        .stateIn(
            scope = viewModelScope,
            started = SharingStarted.WhileSubscribed(5000),
            initialValue = emptyList()
        )

    fun toggleTaskCompletion(task: Task) {
        viewModelScope.launch {
            updateTaskUseCase(task.copy(isCompleted = !task.isCompleted))
        }
    }

    fun deleteTask(task: Task) {
        viewModelScope.launch {
            deleteTaskUseCase(task)
        }
    }
}

Here is the full TaskListScreen.kt:

package com.example.taskmanager.presentation.tasklist

import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Checkbox
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import com.example.taskmanager.domain.model.Priority
import com.example.taskmanager.domain.model.Task

@Composable
fun TaskListScreen(
    onAddTaskClick: () -> Unit,
    viewModel: TaskListViewModel = hiltViewModel()
) {
    val tasks by viewModel.tasks.collectAsState()

    Scaffold(
        floatingActionButton = {
            FloatingActionButton(onClick = onAddTaskClick) {
                Icon(Icons.Default.Add, contentDescription = "Add Task")
            }
        }
    ) { paddingValues ->
        if (tasks.isEmpty()) {
            Column(
                modifier = Modifier
                    .fillMaxSize()
                    .padding(paddingValues),
                horizontalAlignment = Alignment.CenterHorizontally,
                verticalArrangement = Arrangement.Center
            ) {
                Text(
                    text = "No tasks yet!",
                    style = MaterialTheme.typography.headlineSmall
                )
                Spacer(modifier = Modifier.height(8.dp))
                Text(
                    text = "Tap + to add your first task",
                    style = MaterialTheme.typography.bodyMedium
                )
            }
        } else {
            LazyColumn(
                modifier = Modifier
                    .fillMaxSize()
                    .padding(paddingValues)
                    .padding(horizontal = 16.dp),
                verticalArrangement = Arrangement.spacedBy(8.dp)
            ) {
                item { Spacer(modifier = Modifier.height(8.dp)) }
                items(tasks, key = { it.id }) { task ->
                    TaskItem(
                        task = task,
                        onToggleComplete = { viewModel.toggleTaskCompletion(task) },
                        onDelete = { viewModel.deleteTask(task) }
                    )
                }
                item { Spacer(modifier = Modifier.height(8.dp)) }
            }
        }
    }
}

@Composable
fun TaskItem(
    task: Task,
    onToggleComplete: () -> Unit,
    onDelete: () -> Unit
) {
    Card(
        modifier = Modifier.fillMaxWidth(),
        elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)
    ) {
        Row(
            modifier = Modifier
                .fillMaxWidth()
                .padding(12.dp),
            verticalAlignment = Alignment.CenterVertically
        ) {
            Checkbox(
                checked = task.isCompleted,
                onCheckedChange = { onToggleComplete() }
            )
            Spacer(modifier = Modifier.width(8.dp))
            Surface(
                modifier = Modifier.size(12.dp),
                shape = CircleShape,
                color = when (task.priority) {
                    Priority.HIGH -> Color.Red
                    Priority.MEDIUM -> Color(0xFFFFA000)
                    Priority.LOW -> Color.Green
                }
            ) {}
            Spacer(modifier = Modifier.width(12.dp))
            Column(modifier = Modifier.weight(1f)) {
                Text(
                    text = task.title,
                    style = MaterialTheme.typography.titleMedium,
                    textDecoration = if (task.isCompleted) TextDecoration.LineThrough else null,
                    maxLines = 1,
                    overflow = TextOverflow.Ellipsis
                )
                if (task.description.isNotBlank()) {
                    Text(
                        text = task.description,
                        style = MaterialTheme.typography.bodySmall,
                        maxLines = 1,
                        overflow = TextOverflow.Ellipsis
                    )
                }
            }
            IconButton(onClick = onDelete) {
                Icon(
                    Icons.Default.Delete,
                    contentDescription = "Delete Task",
                    tint = MaterialTheme.colorScheme.error
                )
            }
        }
    }
}

Your task list screen is ready to display tasks. Next up, you will build the Add Task screen so users can actually create tasks to populate this list.

Build the Add Task Screen

Your task list screen can display tasks beautifully, but users have no way to create new ones yet.

In this step, you will build the Add Task screen with a form for entering task details. This screen uses its own ViewModel to manage form state, validate input, and communicate back to the list screen when a task is saved.

In this step, get ready to:
  • Create the AddTaskViewModel with form state management and validation.
  • Build the AddTaskScreen composable with text fields, priority selector, and save button.
  • Verify form validation and navigation patterns compile correctly.
Create the AddTaskViewModel

The Add Task screen needs its own ViewModel to hold form state independently from the task list. Each text field, the selected priority, and any validation errors are stored as StateFlow values that the composable UI observes.

  • Right-click the presentation/addtask/ package in the Project panel.
  • Select New then Kotlin Class/File.
  • Name it AddTaskViewModel and select Class.
  • Replace the entire file contents with the following code:
package com.example.taskmanager.presentation.addtask

import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.example.taskmanager.domain.model.Priority
import com.example.taskmanager.domain.model.Task
import com.example.taskmanager.domain.usecase.AddTaskUseCase
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import javax.inject.Inject

@HiltViewModel
class AddTaskViewModel @Inject constructor(
    private val addTaskUseCase: AddTaskUseCase
) : ViewModel() {

    // Form state for each field
    private val _title = MutableStateFlow("")
    val title: StateFlow<String> = _title

    private val _description = MutableStateFlow("")
    val description: StateFlow<String> = _description

    private val _priority = MutableStateFlow(Priority.MEDIUM)
    val priority: StateFlow<Priority> = _priority

    // Validation error state
    private val _titleError = MutableStateFlow<String?>(null)
    val titleError: StateFlow<String?> = _titleError

    // One-time navigation event
    private val _navigateBack = MutableSharedFlow<Unit>()
    val navigateBack: SharedFlow<Unit> = _navigateBack

    fun onTitleChange(value: String) {
        _title.value = value
        _titleError.value = null
    }

    fun onDescriptionChange(value: String) {
        _description.value = value
    }

    fun onPriorityChange(value: Priority) {
        _priority.value = value
    }

    fun saveTask() {
        val currentTitle = _title.value.trim()
        if (currentTitle.length < 3) {
            _titleError.value = "Title must be at least 3 characters"
            return
        }

        viewModelScope.launch {
            val task = Task(
                title = currentTitle,
                description = _description.value.trim(),
                priority = _priority.value
            )
            addTaskUseCase(task)
            _navigateBack.emit(Unit)
        }
    }
}

What does this code do?

Each form field (title, description, priority) gets a private MutableStateFlow paired with a public read-only StateFlow. This pattern lets the UI observe changes without being able to modify state directly.

The navigateBack uses a MutableSharedFlow instead of StateFlow because navigation is a one-time event. A StateFlow would replay the last value to new collectors, causing unwanted repeat navigation.

The saveTask() function validates that the title is at least 3 characters. If validation passes, it creates a Task domain object, saves it through the use case, and emits a navigate-back signal.

  • Save AddTaskViewModel.kt.
  • You won't see a change yet. The next file creates the UI that uses this ViewModel.

Seeing red underlines?

Make sure the package declaration at the top reads com.example.taskmanager.presentation.addtask. A wrong package name breaks the import resolution.

Check that your AddTaskUseCase file exists in domain/usecase/. The ViewModel depends on it through Hilt injection.

Still stuck? Why is my AddTaskViewModel showing unresolved references?

✔️ Awesome, I've got everything!

Great. Make sure you have saved AddTaskViewModel.kt before moving on.

ⓧ I'd like to double check the full code

package com.example.taskmanager.presentation.addtask

import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.example.taskmanager.domain.model.Priority
import com.example.taskmanager.domain.model.Task
import com.example.taskmanager.domain.usecase.AddTaskUseCase
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import javax.inject.Inject

@HiltViewModel
class AddTaskViewModel @Inject constructor(
    private val addTaskUseCase: AddTaskUseCase
) : ViewModel() {

    private val _title = MutableStateFlow("")
    val title: StateFlow<String> = _title

    private val _description = MutableStateFlow("")
    val description: StateFlow<String> = _description

    private val _priority = MutableStateFlow(Priority.MEDIUM)
    val priority: StateFlow<Priority> = _priority

    private val _titleError = MutableStateFlow<String?>(null)
    val titleError: StateFlow<String?> = _titleError

    private val _navigateBack = MutableSharedFlow<Unit>()
    val navigateBack: SharedFlow<Unit> = _navigateBack

    fun onTitleChange(value: String) {
        _title.value = value
        _titleError.value = null
    }

    fun onDescriptionChange(value: String) {
        _description.value = value
    }

    fun onPriorityChange(value: Priority) {
        _priority.value = value
    }

    fun saveTask() {
        val currentTitle = _title.value.trim()
        if (currentTitle.length < 3) {
            _titleError.value = "Title must be at least 3 characters"
            return
        }

        viewModelScope.launch {
            val task = Task(
                title = currentTitle,
                description = _description.value.trim(),
                priority = _priority.value
            )
            addTaskUseCase(task)
            _navigateBack.emit(Unit)
        }
    }
}
Create the AddTaskScreen composable

The screen composable observes the ViewModel's state and renders form fields that react to user input. It includes a TopAppBar for navigation, text inputs for title and description, a priority chip selector, and a save button.

  • Right-click the presentation/addtask/ package.
  • Select New then Kotlin Class/File.
  • Name it AddTaskScreen and select File.
  • Replace the entire file contents with the following code:
package com.example.taskmanager.presentation.addtask

import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.Button
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilterChip
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import com.example.taskmanager.domain.model.Priority

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AddTaskScreen(
    onNavigateBack: () -> Unit,
    viewModel: AddTaskViewModel = hiltViewModel()
) {
    val title by viewModel.title.collectAsState()
    val description by viewModel.description.collectAsState()
    val priority by viewModel.priority.collectAsState()
    val titleError by viewModel.titleError.collectAsState()

    // Collect one-time navigation events
    LaunchedEffect(Unit) {
        viewModel.navigateBack.collect {
            onNavigateBack()
        }
    }

    Scaffold(
        topBar = {
            TopAppBar(
                title = { Text("Add Task") },
                navigationIcon = {
                    IconButton(onClick = onNavigateBack) {
                        Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
                    }
                }
            )
        }
    ) { paddingValues ->
        Column(
            modifier = Modifier
                .fillMaxSize()
                .padding(paddingValues)
                .padding(16.dp)
        ) {
            // Title input with error display
            OutlinedTextField(
                value = title,
                onValueChange = viewModel::onTitleChange,
                label = { Text("Task Title") },
                isError = titleError != null,
                supportingText = titleError?.let { { Text(it) } },
                modifier = Modifier.fillMaxWidth()
            )
            Spacer(modifier = Modifier.height(16.dp))
            // Description input with multi-line support
            OutlinedTextField(
                value = description,
                onValueChange = viewModel::onDescriptionChange,
                label = { Text("Description (optional)") },
                modifier = Modifier.fillMaxWidth(),
                minLines = 3
            )
            Spacer(modifier = Modifier.height(16.dp))
            // Priority selector label
            Text(
                text = "Priority",
                style = MaterialTheme.typography.labelLarge
            )
            Spacer(modifier = Modifier.height(8.dp))
            // Priority chip row
            Row(
                horizontalArrangement = Arrangement.spacedBy(8.dp)
            ) {
                Priority.entries.forEach { p ->
                    FilterChip(
                        selected = priority == p,
                        onClick = { viewModel.onPriorityChange(p) },
                        label = { Text(p.name) }
                    )
                }
            }
            Spacer(modifier = Modifier.height(24.dp))
            // Save button
            Button(
                onClick = viewModel::saveTask,
                modifier = Modifier.fillMaxWidth()
            ) {
                Text("Save Task")
            }
        }
    }
}

What does this code do?

The LaunchedEffect(Unit) block collects the navigateBack SharedFlow. When saveTask() emits a value after a successful save, the composable calls onNavigateBack() to return to the task list.

The OutlinedTextField for the title uses isError and supportingText to display validation errors directly below the field when the title is too short.

The FilterChip row iterates over Priority.entries to generate LOW, MEDIUM, and HIGH options. The selected parameter highlights whichever priority matches the current ViewModel state.

  • Save AddTaskScreen.kt.
  • You won't see a visible change yet because navigation to this screen is wired in the next step.

Unresolved reference to hiltViewModel?

Make sure androidx.hilt:hilt-lifecycle-viewmodel-compose is listed in your app-level build.gradle.kts dependencies. This artifact provides the hiltViewModel() function for composables.

If you see an error on Icons.AutoMirrored.Filled.ArrowBack, confirm your Compose BOM is up to date. This icon was added in Material Icons Extended.

Need more help? Why is my AddTaskScreen showing unresolved references?

✔️ Awesome, I've got everything!

Great. Make sure you have saved AddTaskScreen.kt before moving on.

ⓧ I'd like to double check the full code

package com.example.taskmanager.presentation.addtask

import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.Button
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilterChip
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import com.example.taskmanager.domain.model.Priority

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AddTaskScreen(
    onNavigateBack: () -> Unit,
    viewModel: AddTaskViewModel = hiltViewModel()
) {
    val title by viewModel.title.collectAsState()
    val description by viewModel.description.collectAsState()
    val priority by viewModel.priority.collectAsState()
    val titleError by viewModel.titleError.collectAsState()

    LaunchedEffect(Unit) {
        viewModel.navigateBack.collect {
            onNavigateBack()
        }
    }

    Scaffold(
        topBar = {
            TopAppBar(
                title = { Text("Add Task") },
                navigationIcon = {
                    IconButton(onClick = onNavigateBack) {
                        Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
                    }
                }
            )
        }
    ) { paddingValues ->
        Column(
            modifier = Modifier
                .fillMaxSize()
                .padding(paddingValues)
                .padding(16.dp)
        ) {
            OutlinedTextField(
                value = title,
                onValueChange = viewModel::onTitleChange,
                label = { Text("Task Title") },
                isError = titleError != null,
                supportingText = titleError?.let { { Text(it) } },
                modifier = Modifier.fillMaxWidth()
            )
            Spacer(modifier = Modifier.height(16.dp))
            OutlinedTextField(
                value = description,
                onValueChange = viewModel::onDescriptionChange,
                label = { Text("Description (optional)") },
                modifier = Modifier.fillMaxWidth(),
                minLines = 3
            )
            Spacer(modifier = Modifier.height(16.dp))
            Text(
                text = "Priority",
                style = MaterialTheme.typography.labelLarge
            )
            Spacer(modifier = Modifier.height(8.dp))
            Row(
                horizontalArrangement = Arrangement.spacedBy(8.dp)
            ) {
                Priority.entries.forEach { p ->
                    FilterChip(
                        selected = priority == p,
                        onClick = { viewModel.onPriorityChange(p) },
                        label = { Text(p.name) }
                    )
                }
            }
            Spacer(modifier = Modifier.height(24.dp))
            Button(
                onClick = viewModel::saveTask,
                modifier = Modifier.fillMaxWidth()
            ) {
                Text("Save Task")
            }
        }
    }
}
Verify form validation and navigation patterns

Both files are now in place. Before moving on, confirm that the project compiles successfully with the new add task layer.

  • Click Build in the top menu bar.
  • Select Rebuild Project.
  • Wait for the build to finish. You should see BUILD SUCCESSFUL in the Build output panel at the bottom.

Why SharedFlow instead of StateFlow for navigation?

A StateFlow always holds the latest value and replays it to new collectors. If you used StateFlow for a navigation event, the screen would navigate again every time the composable recomposes or a configuration change happens.

A SharedFlow emits values without replaying them. Each emission is consumed once, making it perfect for one-time events like navigation or showing a snackbar.

Build failed?

If you see annotation processing errors, confirm that com.google.devtools.ksp is applied in your app-level build.gradle.kts. Hilt requires KSP to generate dependency injection code at compile time.

Check that your AddTaskViewModel has the @HiltViewModel annotation and the @Inject annotation on its constructor. Both are required for Hilt to instantiate the ViewModel.

Still stuck? Help me fix my build failure after adding AddTaskViewModel and AddTaskScreen.

Your add task screen is ready and waiting. In the next step, you will wire up Navigation Compose to connect the task list and add task screens together into a working flow.

Set Up Navigation and Connect Everything

You have two fully built screens and all the supporting architecture layers. The missing piece is navigation: telling Android how to move users between the task list and the add task form.

In this step, you will create a navigation graph that maps routes to composable screens, then wire everything together in MainActivity so the app can finally run end-to-end.

In this step, get ready to:
  • Create the navigation graph with route definitions and composable destinations.
  • Update MainActivity with Hilt and the navigation entry point.
  • Test the full app flow on the emulator.
Create the navigation graph

Navigation Compose uses a NavHost to define which composable renders for each route string. You will define routes as a sealed class so typos become compile-time errors instead of runtime crashes.

  • In Android Studio, right-click the presentation/navigation/ package and select New then Kotlin Class/File.
  • Select File and name it NavGraph.
  • Replace the file contents with this code:
package com.example.taskmanager.presentation.navigation

import androidx.compose.runtime.Composable
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import com.example.taskmanager.presentation.addtask.AddTaskScreen
import com.example.taskmanager.presentation.tasklist.TaskListScreen

// Sealed class defines all possible navigation destinations
sealed class Screen(val route: String) {
    data object TaskList : Screen("task_list")
    data object AddTask : Screen("add_task")
}

@Composable
fun NavGraph() {
    val navController = rememberNavController()

    // NavHost maps route strings to composable screens
    NavHost(
        navController = navController,
        startDestination = Screen.TaskList.route
    ) {
        composable(Screen.TaskList.route) {
            TaskListScreen(
                onAddTaskClick = { navController.navigate(Screen.AddTask.route) }
            )
        }
        composable(Screen.AddTask.route) {
            AddTaskScreen(
                onNavigateBack = { navController.popBackStack() }
            )
        }
    }
}

What does this code do?

The Screen sealed class holds route strings as type-safe constants. Using Screen.TaskList.route instead of a raw string means the compiler catches typos for you.

NavHost acts as a container that swaps composables in and out based on the current route. rememberNavController() creates the controller that tracks which screen is active and handles the back stack.

  • Save NavGraph.kt.

You will not see a change yet. The next chunk connects this graph to your activity so the app actually launches with it.

Update MainActivity to use Hilt and the NavGraph

Hilt needs the @AndroidEntryPoint annotation on your activity to inject ViewModels into composables. You also need to set the NavGraph as the root composable inside your theme.

  • Open MainActivity.kt in the root com.example.taskmanager package.
  • Replace the entire file contents with this code:
package com.example.taskmanager

import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import com.example.taskmanager.presentation.navigation.NavGraph
import com.example.taskmanager.ui.theme.TaskManagerTheme
import dagger.hilt.android.AndroidEntryPoint

// Hilt entry point - enables DI for all composables in this activity
@AndroidEntryPoint
class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        enableEdgeToEdge()
        setContent {
            TaskManagerTheme {
                Surface(color = MaterialTheme.colorScheme.background) {
                    NavGraph()
                }
            }
        }
    }
}

What does this code do?

  • @AndroidEntryPoint tells Hilt to generate the dependency injection code for this activity. Without it, hiltViewModel() calls inside your composables would crash at runtime.
  • enableEdgeToEdge() extends your app content behind system bars for a modern full-screen look.
  • Surface applies the Material Design 3 background color, and NavGraph() is the navigation composable you just created.
  • Save MainActivity.kt.

Seeing an unresolved reference error?

  • If TaskManagerTheme shows as unresolved, make sure the import com.example.taskmanager.ui.theme.TaskManagerTheme matches the theme file that Android Studio auto-generated in your ui/theme/ package.
  • If NavGraph is unresolved, confirm you saved the file in the correct package path: presentation/navigation/NavGraph.kt.

Help me fix unresolved reference errors in my MainActivity.

Run the app and test the full flow

Everything is wired. Time to build and run the app to confirm navigation, data persistence, and the complete user flow work together.

  • Click the green Run button (triangle icon) in the Android Studio toolbar, or press Shift+F10 (Windows) / Ctrl+R (macOS).
  • Wait for the app to build and launch on your emulator.
  • You should see the task list screen with the "No tasks yet!" message and a floating action button (FAB) in the bottom right corner.
  • Tap the FAB (+) button to navigate to the Add Task screen.
  • Enter a title (at least 3 characters), an optional description, select a priority, and tap Save Task.
  • The app navigates back to the task list, and your new task appears with its priority color indicator.
  • Tap the checkbox to toggle completion (the title gets a strikethrough) and tap the delete icon to remove a task.
  • Close and reopen the app. Your tasks persist because Room stores them in a local database.

App crashes on launch?

  • Check Logcat for a Hilt error. If you see a message about a missing @HiltAndroidApp annotation, confirm that TaskManagerApplication is registered in your AndroidManifest.xml with android:name=".TaskManagerApplication".
  • If the build fails with annotation processing errors, try Build then Clean Project followed by Build then Rebuild Project.
  • If navigation does not work (tapping the FAB does nothing), confirm that onAddTaskClick in NavGraph.kt calls navController.navigate(Screen.AddTask.route).

Help me debug my Task Manager app crash on launch.

✔️ Awesome, I've got everything!

Great. Double check you have saved both NavGraph.kt and MainActivity.kt.

ⓧ I'd like to double check the full code

Here is the complete NavGraph.kt:

package com.example.taskmanager.presentation.navigation

import androidx.compose.runtime.Composable
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import com.example.taskmanager.presentation.addtask.AddTaskScreen
import com.example.taskmanager.presentation.tasklist.TaskListScreen

sealed class Screen(val route: String) {
    data object TaskList : Screen("task_list")
    data object AddTask : Screen("add_task")
}

@Composable
fun NavGraph() {
    val navController = rememberNavController()

    NavHost(
        navController = navController,
        startDestination = Screen.TaskList.route
    ) {
        composable(Screen.TaskList.route) {
            TaskListScreen(
                onAddTaskClick = { navController.navigate(Screen.AddTask.route) }
            )
        }
        composable(Screen.AddTask.route) {
            AddTaskScreen(
                onNavigateBack = { navController.popBackStack() }
            )
        }
    }
}

Here is the complete MainActivity.kt:

package com.example.taskmanager

import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import com.example.taskmanager.presentation.navigation.NavGraph
import com.example.taskmanager.ui.theme.TaskManagerTheme
import dagger.hilt.android.AndroidEntryPoint

@AndroidEntryPoint
class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        enableEdgeToEdge()
        setContent {
            TaskManagerTheme {
                Surface(color = MaterialTheme.colorScheme.background) {
                    NavGraph()
                }
            }
        }
    }
}

Your Task Manager app is fully functional. You have a production-grade architecture with Clean Architecture layers, Hilt dependency injection, Room persistence, and Navigation Compose all working together. Next up: the Secret Mission where you will add search and filtering to level up the user experience.

Secret mission

Add Task Filtering and Search

Your task list works, but as it grows, finding a specific task becomes tedious. Add a search bar and priority filter so users can instantly narrow down their tasks.

Clean Up Your Resources

Clean Up Your Resources

Decide whether to keep your resources running, pause them to come back later, or delete them entirely.

This project runs entirely locally on your machine, so there are no ongoing cloud costs.

Resources you used:

  • The TaskManager Android Studio project folder (source code, Gradle files, build outputs).
  • The Android Emulator virtual device (AVD) used for testing.
  • The Room database stored inside the emulator's app data.

Keep everything running

No action needed. Choose this if you are still actively building or want to continue experimenting with the Task Manager app.

  • Your project files remain in Android Studio, ready to open any time.
  • Your emulator virtual device stays available in the Device Manager.
  • Any tasks you added to the Room database persist inside the emulator.

Pause - I'll come back to this later

Shut down running processes to free up memory while keeping all your files intact.

  • Stop the emulator by clicking the X button on the emulator window or pressing the power icon in the toolbar.
  • Close Android Studio to free up RAM.
  • Your project files and AVD image remain on disk. Reopen Android Studio and the project any time to resume.

Delete - I don't want to use this again

Remove all project resources and start fresh.

  • Delete the emulator virtual device by opening Android Studio, clicking Device Manager in the right-side panel, right-clicking your device, and selecting Delete.
  • Delete the project folder. On macOS or Linux, run the command below. On Windows, use File Explorer to delete the folder.
rm -rf ~/AndroidStudioProjects/TaskManager

Reclaim build cache space

Android Studio stores Gradle build caches in ~/.gradle/caches/ which can grow large over time. Deleting this folder is safe. Gradle recreates it the next time you build any project.

Nice Work!

Nice Work!

You just built a production-quality Task Manager app using the same architecture patterns found in professional Android development.

You've learned how to:

  • Structure an Android app with Clean Architecture (domain, data, and presentation layers) wired together with Hilt dependency injection.
  • Build reactive UI with Jetpack Compose and manage state using ViewModel + StateFlow for a fully reactive data pipeline.
  • Persist data locally with a Room database and navigate between multiple screens using Navigation Compose.
  • Secret Mission: Added task filtering by priority and a search feature using the Flow combine() operator.

Ready to quiz yourself?