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.
- 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" }}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
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.