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
Taskdata class withid,title, and a mutableisDoneflag, gettingtoString()/equals()/copy()for free. - A
TaskManagerclass that owns aMutableList<Task>and assigns unique ids automatically. - An
addTask()operation that appends a newTaskto 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 itsisDoneflag. - A
removeTask()operation built on Kotlin'sMutableList.removeIf(). - A
readLine()-driven menu loop dispatched with awhenexpression.
Prerequisites
- Classes and data classes — the
data classkeyword and what it generates automatically. - Collections basics —
MutableList,mutableListOf(), and iterating with aforloop. - Console input — reading lines with
readLine()and convertingStringtoIntwith.toIntOrNull(). - The
whenexpression — Kotlin's replacement for a longif/else if/switchchain. - 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 = 0field toTaskand asortByPriority()function usingtasks.sortedByDescending { it.priority }. - Add a
dueDate: LocalDate?nullable field and a function that filters overdue tasks withtasks.filter { it.dueDate?.isBefore(LocalDate.now()) == true }. - Persist tasks to a text file by mapping each
Taskto a comma-separated line and reading it back on startup. - Use
task.copy(isDone = true)insidecompleteTask()and replace the element in place, to practice the immutable-update style instead of mutatingisDonedirectly. - Add a
findByTitle(query: String)function usingtasks.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.