The planner can now reject unsuitable input before constructing a task. Its next weakness is less visible: lesson 4 completed tasks by matching their titles. That works until two tasks have the same name. Read Kotlin this morning and Read Kotlin tomorrow are different pieces of work, even when their visible titles match. This lesson gives each task a stable identity and assigns one small class responsibility for storing and updating the task collection.
The experienced approach is to start with the failure you can reproduce. Create two equally named tasks and ask which one should become complete. If the answer is the task the user selected, then its title alone is insufficient information. We will solve that concrete problem without adding a database, inheritance tree, or framework. The result remains a console program you can understand from its inputs and checks.
Decide what belongs to the model
StudyTask gains an integer id. Its title, minutes, and completion flag remain values that describe the task. The identifier remains the same when a task becomes complete. This distinction lets a copy represent the next state of the same logical task. Changing its title later would also preserve the identifier. Avoid using a list position as identity because positions can change after filtering, sorting, or removing other tasks.
For this exercise the planner allocates increasing positive integers during one run. That is sufficient for a small in-memory example. It is not a persistent identity strategy across program restarts or multiple devices. We will say exactly where it stops working, because otherwise a demonstration can quietly turn into an application with assumptions nobody remembers. Storage and synchronization belong to later projects with their own rules.
Give one class responsibility for changes
StudyPlanner owns a private mutable list and a private counter. Other code asks it to add a task, complete a task by ID, or return a snapshot. The class does not print feedback or parse raw strings. That separation keeps its operations useful from a console, a test, or a future interface. Lesson 5 handles text; this class handles already accepted values and maintains its own safeguards against invalid direct calls.
Private storage is valuable because every meaningful update travels through a named operation. A caller cannot casually clear the planner's list while another part of the program assumes it still contains pending tasks. This is not about hiding information from colleagues. It is about keeping the places where state can change small enough to inspect, so an unexpected total has a short and understandable trail back to its cause.
Run the complete ownership example
Paste this program into a fresh Playground editor. It deliberately includes two tasks with identical titles. Before execution, predict which identifier will be completed and whether the first snapshot will change after that operation.
data class StudyTask(
val id: Int,
val title: String,
val minutes: Int,
val done: Boolean = false
) {
init {
require(id > 0) { "ID must be positive" }
require(title.isNotBlank()) { "Title must not be blank" }
require(minutes in 1..240) { "Minutes must be from 1 to 240" }
}
}
class StudyPlanner {
private val tasks = mutableListOf<StudyTask>()
private var nextId = 1
fun add(title: String, minutes: Int): StudyTask {
check(nextId < Int.MAX_VALUE) { "ID capacity exhausted" }
val task = StudyTask(nextId, title, minutes)
tasks.add(task)
nextId += 1
return task
}
fun complete(id: Int): Boolean {
val index = tasks.indexOfFirst { it.id == id }
if (index == -1) return false
tasks[index] = tasks[index].copy(done = true)
return true
}
fun snapshot(): List<StudyTask> = tasks.toList()
}
fun main() {
val planner = StudyPlanner()
val first = planner.add("Read Kotlin", 15)
val second = planner.add("Read Kotlin", 25)
val before = planner.snapshot()
check(first.id != second.id)
check(planner.complete(second.id))
check(!planner.complete(999))
val after = planner.snapshot()
check(!before[1].done)
check(!after[0].done)
check(after[1].done)
check(after[1].id == second.id)
check(after[1].title == first.title)
check(after.filter { !it.done }.sumOf { it.minutes } == 15)
check(planner.complete(second.id))
check(planner.snapshot() == after)
val callerCopy = planner.snapshot().toMutableList()
callerCopy.clear()
check(planner.snapshot().size == 2)
after.forEach { println("${it.id}: ${it.title}, done=${it.done}") }
println("Ownership checkpoint passed")
}
The first displayed task has identifier 1 and remains unfinished. The second has identifier 2 and is complete. The planner still contains both tasks after a caller clears its own mutable copy. These observations tell us more than a successful compilation: the operation chose the right identity, preserved a previous view of the data, and kept collection ownership where we intended it to be.
Understand why val does not freeze a list
The tasks property uses val, yet add still modifies the collection. A read-only reference prevents assigning a different list to that property; it does not make the referenced mutable list unchangeable. Confusing these two ideas leads to code that looks safe from a distance while several callers can still modify the same object. Name the owner of mutation explicitly rather than assuming the declaration keyword settled the issue.
The snapshot operation copies the list structure. Its elements are still references to StudyTask values. In this particular model, those values have only read-only integer, string, and boolean properties, so replacing an internal element does not modify a task visible in an earlier snapshot. If the model later contains a mutable list of subtasks, this reasoning must be revisited. A shallow copy would continue sharing those nested mutable objects.
Keep an update contract you can explain
complete returns true when the identifier exists, including when that task is already complete. It returns false when no task has that identifier. That makes repeated completion harmless for the task state, but it does not report whether this particular call changed anything. A future interface that needs separate already-complete feedback should use a more descriptive outcome. For now, the Boolean contract is small, explicit, and checked.
Avoid inferring success from whether an exception occurred. A nonexistent task is an ordinary outcome in this planner: a selection might become stale after an interface refresh. Bad constructor values are different; they violate the caller's agreement with add. Deciding which outcomes are expected before writing the method makes feedback and debugging much easier. Otherwise exceptions, nulls, and Booleans accumulate without a shared meaning.
Trace the order of changes
add constructs a valid task before advancing the counter. If construction rejects a title or duration, no task enters storage and the counter stays unchanged. The capacity check also runs before allocation. Small examples rarely approach the maximum integer value, but guarding the counter documents its limited design instead of relying on an unnoticed overflow. This remains a single-threaded example; the guard does not make the class safe for concurrent writers.
When an update behaves incorrectly, inspect identity first, then lookup, then replacement. A duplicate title can look like a collection bug when the real problem is choosing an ambiguous key. A missing index can look like a copy bug when lookup found nothing. Breaking the operation into those steps makes diagnosis practical, and the local index is worth keeping even if a clever expression could compress the method.
Practice another operation without exposing storage
Add a remove function receiving an identifier and returning whether a matching task was removed. First write the expected behavior in plain language. Removing the first task should leave the second task with its original identifier. Removing the same identifier again should report failure. An earlier snapshot should still contain both original entries. Implement and check those expectations before considering any user-interface behavior.
Then add a pendingMinutes member that calculates the current unfinished total without returning the mutable list. Compare it with the calculation in main. Consider which choice makes a clearer boundary for this small planner: a model-specific query method or a standalone calculation over a snapshot. There is room for either design, provided the storage owner stays explicit and callers cannot change tasks through an accidental shared reference.
Prepare the reporting step
Finish this checkpoint by creating two identically named tasks with different durations, completing the second by ID, and explaining why the first snapshot still shows both as unfinished. Keep that program as a reference. Lesson 7 will consume planner snapshots to build different reports, giving us a reason to pass behavior into a function without granting it permission to modify storage.
Read the official explanations of classes, visibility modifiers, and read-only and mutable collections when reviewing the syntax. The deeper lesson is a maintenance habit: give identity a stable meaning, make changes deliberate, and verify what remains true after the change. Those decisions survive when the training program eventually grows an interface or persistent storage.
