LearnAI ToolsCareerPractice BuildsPlayDevelopmentContact
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.