LearnAI ToolsCareerPractice BuildsPlayContact
KotlinBeginner~1.5 hours

Console Task Manager

Build a command-line to-do app using data classes and mutable collections to track tasks.

Data ClassesCollectionsControl Flow

Overview

A console task manager is the natural first Kotlin project because it puts `data class` front and center: a `Task` is really just a bundle of a title, a completion flag, and an id that all travel together, and that is precisely what `data class` was designed to express. Declaring `Task` as a data class gives you a correct `toString()`, `equals()`, and `copy()` for free, so you never hand-write the boilerplate that a comparable Java class would need — this is one of the clearest places where Kotlin is not "Java with different syntax" but a genuinely more concise language for modeling data.

By the end of this tutorial you will have a command-line to-do application built around a `Task` data class and a `TaskManager` class that owns a `MutableList<Task>`. You will drive the whole program with `readLine()` and a `when` expression instead of an `if`/`else if` chain, which is the idiomatic Kotlin way to dispatch on a menu choice — `when` reads top to bottom like a table of cases rather than a sequence of independent conditions.

What You'll Build
  • A `Task` data class with `id`, `title`, and a mutable `isDone` flag, getting `toString()`/`equals()`/`copy()` for free.
  • A `TaskManager` class that owns a `MutableList<Task>` and assigns unique ids automatically.
  • An `addTask()` operation that appends a new `Task` to the list.
  • A `listTasks()` operation that prints every task with a `[x]`/`[ ]` completion marker.
  • A `completeTask()` operation that finds a task by id and flips its `isDone` flag.
  • A `removeTask()` operation built on Kotlin's `MutableList.removeIf()`.
  • A `readLine()`-driven menu loop dispatched with a `when` expression.

Prerequisites

  • Classes and data classes — the `data class` keyword and what it generates automatically.
  • Collections basics — `MutableList`, `mutableListOf()`, and iterating with a `for` loop.
  • Console input — reading lines with `readLine()` and converting `String` to `Int` with `.toIntOrNull()`.
  • The `when` expression — Kotlin's replacement for a long `if`/`else if`/`switch` chain.
  • Function basics — parameters, return types, and calling one function from another.

Project Structure

The whole program lives in a single file, `ConsoleTaskManager.kt`, containing one data class, `Task`, one regular class, `TaskManager`, and a top-level `fun main()` — Kotlin does not require every function or class to sit inside a `public class` the way Java does, so `main()` can be a free-standing top-level function in the file. `Task` is a pure data holder; it never touches the console or the list it lives in. `TaskManager` is the layer above it: it owns the `MutableList<Task>`, hands out ids, and exposes one function per menu action (`addTask`, `listTasks`, `completeTask`, `removeTask`). `main()` never manipulates the list directly; it only ever calls functions on a single shared `TaskManager` instance, which keeps every rule about how tasks are created or removed enforced in exactly one place.

Step 1: Define the Task Data Class

`data class Task` is declared with `var isDone` (mutable) alongside `val id` and `val title` (both read-only after construction) — `id` never changes once assigned, `title` is left fixed for this project too, but `isDone` has to change in place every time a task is completed, which is exactly why it is the one `var` in the class. Because this is a `data class`, Kotlin auto-generates `toString()`, so printing a `Task` directly already produces readable output without any manual override.

// A data class is Kotlin's purpose-built tool for a class that is mostly a
// bundle of values: the compiler generates toString(), equals(), hashCode(),
// and copy() for us, so Task never needs any of that written by hand.
data class Task(
val id: Int, // Assigned by TaskManager, never chosen by the caller — val because an id never changes
val title: String, // What the task is; val because this project never edits a task's title in place
var isDone: Boolean = false // Mutable: completeTask() flips this after construction, so it must be var
)

Step 2: Build the TaskManager and Add Tasks

`TaskManager` holds `private val tasks = mutableListOf<Task>()` — `val` here means the *reference* to the list never changes, not that the list's contents are frozen; `mutableListOf()` returns a `MutableList`, which is what actually allows `add()` and `removeIf()` to mutate it later. `nextId` is a simple auto-incrementing counter, so `TaskManager`, not the caller, decides every task's id, guaranteeing no two tasks can ever collide.

// Owns every Task in memory and is the only class allowed to create, list,
// complete, or remove them — main() never touches the MutableList directly,
// it only ever calls functions on a TaskManager.
class TaskManager {
private val tasks = mutableListOf<Task>() // val = the reference is fixed; the list itself is still mutable
private var nextId = 1 // Next id to hand out; incremented after every successful add
fun addTask(title: String): Task {
val task = Task(nextId, title) // TaskManager assigns the id, not the caller; isDone defaults to false
tasks.add(task) // MutableList.add() appends in amortized O(1) time
nextId++ // Guarantees every future task gets a fresh id
return task // Handy for immediately printing "Added: ..."
}
}

Step 3: List and Complete Tasks

`listTasks()` walks the list with a plain `for` loop, printing a `[x]` or `[ ]` marker built from a Kotlin `if` *expression* (not statement) — in Kotlin `if`/`else` evaluates to a value, so `if (task.isDone) "x" else " "` is a single expression usable directly inside a string template. `completeTask()` uses `tasks.find { it.id == id }`, a collection function that returns the first matching element or `null`, paired with Kotlin's safe-call operator `?.` so the flag flip only happens when a match was actually found.

fun listTasks() {
if (tasks.isEmpty()) {
println("No tasks yet.")
return
}
for (task in tasks) { // for-in walks every task in insertion order
val marker = if (task.isDone) "x" else " " // if is an expression in Kotlin, so this yields a value directly
println("[$marker] ${task.id}: ${task.title}")
}
}
fun completeTask(id: Int): Boolean {
val task = tasks.find { it.id == id } // find{} returns the first match or null — never throws if nothing matches
return if (task != null) {
task.isDone = true // Only reachable once null has been ruled out, so this is safe
true
} else {
false // No task with that id; let the caller report "not found"
}
}
Example Usage

Click Run to see what this code prints.

Step 4: Remove a Task

`removeTask()` uses `MutableList.removeIf()`, which takes a lambda predicate and removes every element the predicate matches, returning `true` only if at least one element was actually removed. Since ids are unique, at most one task is ever removed here, but `removeIf()` is still the cleanest way to express "delete the task whose id equals this one" without manually managing a loop index while removing from the list being iterated — the same idiom Kotlin shares with Java's `ArrayList.removeIf()`.

fun removeTask(id: Int): Boolean {
return tasks.removeIf { it.id == id } // Trailing-lambda syntax; true only if a matching task was removed
}

Step 5: Build the Menu Loop With when

`main()` creates one `TaskManager` shared by every menu action and loops with `while (true)`, using `when (choice)` to dispatch on the user's numeric input — Kotlin's idiomatic replacement for an `if`/`else if` chain or a Java `switch`. Each branch is a single case, `else` catches anything unrecognized, and `readLine()?.toIntOrNull() ?: -1` is the Kotlin-native way to read a line of input and safely fall back to an invalid sentinel value instead of crashing if the user types something that is not a number.

fun main() {
val manager = TaskManager() // One manager instance shared by every menu action below
while (true) { // Loop forever; the "5. Exit" branch is the only way out, via return
println("\n===== CONSOLE TASK MANAGER =====")
println("1. Add Task")
println("2. List Tasks")
println("3. Complete Task")
println("4. Remove Task")
println("5. Exit")
print("Enter your choice: ")
val choice = readLine()?.toIntOrNull() ?: -1 // Null input or non-numeric text safely becomes -1, not a crash
when (choice) { // when is Kotlin's idiomatic dispatch on a menu choice, replacing if/else-if chains
1 -> {
print("Enter task title: ")
val title = readLine().orEmpty() // orEmpty() turns a possible null into "" instead of crashing
val added = manager.addTask(title)
println("Added! Assigned ID: ${added.id}")
}
2 -> manager.listTasks()
3 -> {
print("Enter ID to complete: ")
val id = readLine()?.toIntOrNull() ?: -1
val done = manager.completeTask(id)
println(if (done) "Task marked complete." else "No task with that ID.")
}
4 -> {
print("Enter ID to remove: ")
val id = readLine()?.toIntOrNull() ?: -1
val removed = manager.removeTask(id)
println(if (removed) "Task removed." else "No task with that ID.")
}
5 -> {
println("Goodbye!")
return // Exits main() directly, ending the program
}
else -> println("Invalid choice, try again.") // Catches anything outside 1-5
}
}
}

Complete Code

Here is the full program with every declaration assembled in the correct order, ready to save as `ConsoleTaskManager.kt` and run with `kotlinc ConsoleTaskManager.kt -include-runtime -d task-manager.jar && java -jar task-manager.jar`.

data class Task(
val id: Int,
val title: String,
var isDone: Boolean = false
)
class TaskManager {
private val tasks = mutableListOf<Task>()
private var nextId = 1
fun addTask(title: String): Task {
val task = Task(nextId, title)
tasks.add(task)
nextId++
return task
}
fun listTasks() {
if (tasks.isEmpty()) {
println("No tasks yet.")
return
}
for (task in tasks) {
val marker = if (task.isDone) "x" else " "
println("[$marker] ${task.id}: ${task.title}")
}
}
fun completeTask(id: Int): Boolean {
val task = tasks.find { it.id == id }
return if (task != null) {
task.isDone = true
true
} else {
false
}
}
fun removeTask(id: Int): Boolean {
return tasks.removeIf { it.id == id }
}
}
fun main() {
val manager = TaskManager()
while (true) {
println("\n===== CONSOLE TASK MANAGER =====")
println("1. Add Task")
println("2. List Tasks")
println("3. Complete Task")
println("4. Remove Task")
println("5. Exit")
print("Enter your choice: ")
val choice = readLine()?.toIntOrNull() ?: -1
when (choice) {
1 -> {
print("Enter task title: ")
val title = readLine().orEmpty()
val added = manager.addTask(title)
println("Added! Assigned ID: ${added.id}")
}
2 -> manager.listTasks()
3 -> {
print("Enter ID to complete: ")
val id = readLine()?.toIntOrNull() ?: -1
val done = manager.completeTask(id)
println(if (done) "Task marked complete." else "No task with that ID.")
}
4 -> {
print("Enter ID to remove: ")
val id = readLine()?.toIntOrNull() ?: -1
val removed = manager.removeTask(id)
println(if (removed) "Task removed." else "No task with that ID.")
}
5 -> {
println("Goodbye!")
return
}
else -> println("Invalid choice, try again.")
}
}
}

Sample Run

Sample Run

Click Run to see what this code prints.

Extend This Project

  • Add a `priority: Int = 0` field to `Task` and a `sortByPriority()` function using `tasks.sortedByDescending { it.priority }`.
  • Add a `dueDate: LocalDate?` nullable field and a function that filters overdue tasks with `tasks.filter { it.dueDate?.isBefore(LocalDate.now()) == true }`.
  • Persist tasks to a text file by mapping each `Task` to a comma-separated line and reading it back on startup.
  • Use `task.copy(isDone = true)` inside `completeTask()` and replace the element in place, to practice the immutable-update style instead of mutating `isDone` directly.
  • Add a `findByTitle(query: String)` function using `tasks.filter { it.title.contains(query, ignoreCase = true) }`.

Summary

You built a working console task manager where `Task` is a compact `data class` that gets its `toString()`, `equals()`, and `copy()` for free, and `TaskManager` composes many tasks into one manageable, mutable collection. The `when`-driven menu loop you wrote here is the same idiomatic dispatch pattern you will reuse in almost every menu-driven Kotlin console application from here on, and the add/list/complete/remove shape generalizes directly to any collection-backed feature you build next.