You now know how to run Kotlin, store values, and move rules into functions. This lesson combines those skills into a study planner. Each task has a title, a duration, and a completion flag. The program lists unfinished work, totals the time still needed, and finds a task by name. A search with no match should produce a useful message without crashing the program.
This project runs in the console. It does not save data after execution or provide an input form. That scope lets you inspect the model and transformations before introducing storage or an interface. Allow a practice session to type the example, change its inputs, and explain which operations create new values. Keep a saved version of the working program before starting the extension exercise.
Model a task with a data class
StudyTask has three properties: title is a String, minutes is an Int, and done is a Boolean. The default completion value is false because a newly planned task usually remains unfinished. A data class provides useful data-oriented behavior, including equality based on primary-constructor properties and a copy function. We use these features to make updates explicit and keep the example easy to inspect.
All three properties use val. To complete a task, we create a copy with done set to true. This makes the point where a new value is produced visible. The current model contains only strings, integers, and booleans, so there is no nested mutable collection to consider. In richer models, remember that copy is shallow: it does not recursively duplicate every referenced object inside the original.
Run the complete planner
data class StudyTask(
val title: String,
val minutes: Int,
val done: Boolean = false
) {
init {
require(title.isNotBlank()) { "Task title must not be blank" }
require(minutes > 0) { "Duration must be positive" }
}
}
fun pendingMinutes(tasks: List<StudyTask>): Int =
tasks.filter { !it.done }.sumOf { it.minutes }
fun main() {
val tasks = listOf(
StudyTask("Read about variables", 15, true),
StudyTask("Practice functions", 20),
StudyTask("Build the project", 25)
)
val pending = tasks.filter { !it.done }
println("Unfinished tasks:")
pending.forEach { println(it.title + ": " + it.minutes + " minutes") }
println("Remaining: " + pendingMinutes(tasks) + " minutes")
val found = tasks.firstOrNull { it.title == "Practice functions" }
println("Found: " + (found?.title ?: "No matching task"))
val missing = tasks.firstOrNull { it.title == "Learn networking" }
println("Another search: " + (missing?.title ?: "No matching task"))
val updated = tasks.map {
if (it.title == "Practice functions") it.copy(done = true) else it
}
check(pendingMinutes(tasks) == 45)
check(pendingMinutes(updated) == 25)
check(pendingMinutes(emptyList()) == 0)
check(tasks[1].done == false)
println("After update: " + pendingMinutes(updated) + " minutes")
}
The unfinished list contains Practice functions for twenty minutes and Build the project for twenty-five, with 45 remaining minutes in total. The first search finds Practice functions; the second reports No matching task. After the update, remaining time becomes 25. The checks confirm that the original list still contains an unfinished functions task while the updated list reflects completion.
Understand a filtering lambda
The expression in braces passed to filter is a lambda: a small piece of behavior supplied to another function. In a lambda with a single unnamed parameter, it refers to the task currently being examined. The condition !it.done selects tasks whose completion flag is false. filter returns a list of matches; it does not complete tasks or remove them from the original collection.
You can write a named parameter instead, such as task followed by an arrow and !task.done. Both forms express the same rule here. Named parameters become more helpful when a lambda grows or when several lambdas are nested. Favor a condition a reader can understand immediately instead of shortening every expression as far as the syntax permits.
Total and transform the collection
sumOf selects minutes from each task and adds the values. pendingMinutes first filters unfinished tasks and then totals their durations. An empty list gives zero, which matches the meaning of having no pending work. The loop in the previous lesson performed addition step by step; collection functions now express the desired operation directly while keeping the same underlying reasoning available to you.
map creates a result list by transforming each element. The updated calculation replaces Practice functions with a completed copy and preserves the other values. Real task names can repeat, so a production planner should use a separate identifier for updates. This exercise deliberately uses unique titles to focus on map and copy. Keep that limitation in mind before applying the example to a larger collection of user-created tasks.
Handle searches that return nothing
firstOrNull returns the first matching element or null when no match exists. The search results therefore have nullable types. The safe-call operator ?. reads title only when the task exists, and the Elvis operator ?: supplies fallback text when the left-hand result is null. Here a missing match is an ordinary search outcome, so a clear message is appropriate.
Do not use !! merely to silence the compiler's nullable-value requirements. For missing, asserting that a task exists would fail at runtime. A blank title is also different from an absent task: blank input is invalid task data and is rejected by the constructor. Keeping absence separate from malformed data helps you choose meaningful behavior at each boundary.
Validate the model's inputs
The init block runs during object construction. isNotBlank rejects an empty title or one containing only whitespace; minutes > 0 requires a positive duration. require throws IllegalArgumentException if a condition fails. Try constructing a zero-minute task to observe the message, then remove that experiment from the main example so the remaining checks can execute normally.
An application with a form would usually show validation feedback beside its input before creating the model. Constructor checks protect the object's rules but do not replace helpful interaction with the person entering data. Int also has a finite range. This example assumes a small list of ordinary study durations; if your data can grow much larger, choose a suitable numeric type and overflow policy.
Finish the foundation project
Add an unfinished Review task lasting ten minutes. The initial pending total should increase from 45 to 55, and the total after completing Practice functions should become 35. Then complete every task using map and copy and check that pending time becomes zero. Finally, search for a nonexistent title and confirm that the program still prints the fallback message.
You finish the foundation stage when you can rebuild the model, explain filter versus map, and combine firstOrNull with safe calls and an explicit fallback. Continue in order with lessons 5–8 to accept raw input, add stable task IDs, build reports, and verify the integrated planner. The separate Kotlin practice articles remain available for deeper exercises after the course. Review data classes, lambdas, and null safety when you revisit those concepts.
