Phase 2: Packing Checklist
Build a packing checklist: add a name, mark an item packed, and remove a row. Learn forms, immutable list updates, callbacks, and stable IDs. The list intentionally stays in memory; permanent storage comes later.
Guided workshop: 2–3 hours
Before you start
- State hoisting
- Input validation
- LazyColumn and stable IDs
Application structure and responsibilities
- One screen with a single main task.
Concepts to explain: Data classes · Immutable lists · remember state lifetime · State hoisting · Callbacks · LazyColumn stable keys · Input validation · Derived totals
What you will practice
- Data classes
- Immutable lists
- remember state lifetime
- State hoisting
- Callbacks
- LazyColumn stable keys
- Input validation
- Derived totals
Download the complete project and run it
Each ZIP includes the application source, build scripts, Gradle wrapper, manifest, resources and README. The full solution is also readable below; no feature is left as an unfinished TODO.
- Extract the ZIP. In Android Studio, open the project folder containing
settings.gradle.kts. - Use JDK 17 or 21 and install Android SDK Platform 36 plus the Build Tools requested by Android Studio.
- Allow the first Gradle sync to download dependencies. No backend account or API key is needed.
- Select an emulator or device running API 23 or newer, then run the
appconfiguration. - Start with the expected results below. Open
README.mdandTOOLCHAIN.mdfor the exact project instructions.
.\gradlew.bat :app:assembleDebug :app:testDebugUnitTestKeep the first version small
Implementation roadmap
- 1. Open the app and identify one row of data: The starting screen has no records, shows an empty state, and has a disabled Add item button.
- 2. Keep changing values in the route: Typing updates draft and enables Add for nonblank input. Rotation resets the in-memory example.
- 3. Validate and add one record: Adding a padded name such as two spaces, Socks, and two spaces creates the label Socks and clears the text field.
- 4. Toggle and delete by stable identity: Checking the second of two equal names affects only the second row. Deleting the first preserves the second row and its checked value.
- 5. Render a stateless list and derive the total: The packed total reflects the current checked rows, and the empty state returns when the last row is deleted.
- 6. Connect row callbacks and verify the scope: Each row responds independently, blank input is rejected, and activity recreation starts a documented fresh list.
Build the app step by step
Follow these steps in order. Each code window shows the relevant part of a real project file; the complete, unabridged files are in the source browser and ZIP. Run and check each small change before moving on.
Step 1: Open the app and identify one row of data
Open the extracted project folder in Android Studio, sync, and run app on API 23+. Start with PackingItem: id identifies a record, name is its label, and packed stores its checkbox state. IDs are numeric and increase for each addition. Two items may have the same name and still be separate records.
Open: app/src/main/java/com/mastercoding/phase2/MainActivity.kt
import androidx.compose.ui.unit.dp
data class PackingItem(val id: Int, val name: String, val packed: Boolean = false)
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
MaterialTheme {
Scaffold { innerPadding ->
PackingListRoute(Modifier.padding(innerPadding).consumeWindowInsets(innerPadding))
}
}
}
}
}
@ComposableExpected behavior
Step 2: Keep changing values in the route
PackingListRoute owns items, draft, and nextId. All use remember, which deliberately keeps this exercise in memory. They survive recomposition but are recreated when the activity is recreated. PackingListScreen receives values and callbacks, allowing the screen to display state without deciding where it lives.
Open: app/src/main/java/com/mastercoding/phase2/MainActivity.kt
@Composable
fun PackingListRoute(modifier: Modifier = Modifier) {
// These records are intentionally in memory: activity recreation starts a new list.
var items by remember { mutableStateOf(emptyList<PackingItem>()) }
var draft by remember { mutableStateOf("") }
var nextId by remember { mutableIntStateOf(1) }
PackingListScreen(
items = items,
draft = draft,
onDraftChange = { draft = it },
onAdd = {
val name = draft.trim()
if (name.isNotEmpty()) {
items = items + PackingItem(nextId, name)
nextId += 1
draft = ""
}Expected behavior
Step 3: Validate and add one record
onAdd trims the draft, checks that the result is not empty, appends a PackingItem using nextId, increments the ID, and clears the input. The screen disables Add for blank text, while the callback validates again at the state boundary. Replacing the immutable list gives Compose a new state value.
Open: app/src/main/java/com/mastercoding/phase2/MainActivity.kt
onDraftChange = { draft = it },
onAdd = {
val name = draft.trim()
if (name.isNotEmpty()) {
items = items + PackingItem(nextId, name)
nextId += 1
draft = ""
}
},
onToggle = { id ->
items = items.map { if (it.id == id) it.copy(packed = !it.packed) else it }
},
onDelete = { id -> items = items.filterNot { it.id == id } },
modifier = modifier
)
}
@Composable
fun PackingListScreen(Expected behavior
Step 4: Toggle and delete by stable identity
onToggle maps over the list and copies only the item whose ID matches the selected row. onDelete filters out that ID. Neither callback mutates a record in place. Using an ID instead of a position ensures the intended item is selected after another row is removed.
Open: app/src/main/java/com/mastercoding/phase2/MainActivity.kt
}
},
onToggle = { id ->
items = items.map { if (it.id == id) it.copy(packed = !it.packed) else it }
},
onDelete = { id -> items = items.filterNot { it.id == id } },
modifier = modifier
)
}
@Composable
fun PackingListScreen(
items: List<PackingItem>,
draft: String,
onDraftChange: (String) -> Unit,
onAdd: () -> Unit,
onToggle: (Int) -> Unit,
onDelete: (Int) -> Unit,
modifier: Modifier = ModifierExpected behavior
Step 5: Render a stateless list and derive the total
PackingListScreen displays its parameters. LazyColumn renders a header, optional empty message, and keyed item rows. key = { it.id } ties composition identity to the record. The packed total is counted from the current list, so additions, toggles, and deletions cannot leave an independently stored counter stale. imePadding() keeps the form reachable above the keyboard; the Activity consumes its already-applied Scaffold insets to avoid double padding.
Open: app/src/main/java/com/mastercoding/phase2/MainActivity.kt
item(key = "empty") { Text("Your list is empty. Add your first item above.") }
}
items(items = items, key = { it.id }) { item ->
PackingItemRow(item = item, onToggle = { onToggle(item.id) }, onDelete = { onDelete(item.id) })
}
item(key = "footer") { Text("Activity recreation starts a fresh list.", Modifier.padding(bottom = 24.dp)) }
}
}
@Composable
fun PackingItemRow(item: PackingItem, onToggle: () -> Unit, onDelete: () -> Unit) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Checkbox(
checked = item.packed,
onCheckedChange = { onToggle() },Expected behavior
Step 6: Connect row callbacks and verify the scope
PackingItemRow receives one item and two callbacks. The checkbox delegates its event upward; the Delete button does the same. Checked state is exposed by Checkbox, and strikethrough adds a visual cue. Run the duplicate-name and recreation checks. Inline English teaching text can be localized later; persistence belongs to a later Room lesson.
Open: app/src/main/java/com/mastercoding/phase2/MainActivity.kt · README.md
@Composable
fun PackingItemRow(item: PackingItem, onToggle: () -> Unit, onDelete: () -> Unit) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Checkbox(
checked = item.packed,
onCheckedChange = { onToggle() },
modifier = Modifier.semantics { contentDescription = "Packed: ${item.name}" }
)
Text(
text = item.name,
modifier = Modifier.weight(1f),
textDecoration = if (item.packed) TextDecoration.LineThrough else TextDecoration.None
)
TextButton(Expected behavior
Complete source code — every file
Start with MainActivity.kt, then follow the file guide. The selector also contains the tests, manifest, resources and complete Gradle configuration. The binary Gradle wrapper JAR is included in the download.
| File | Purpose |
|---|---|
| app/src/main/java/com/mastercoding/phase2/MainActivity.kt | Complete activity, item model, state owner, stateless screen, and row composable. Every add, toggle, and delete callback is included. |
| README.md | Run instructions, line-of-reasoning build sequence, lifetime limitation, and manual behavior checks. |
Complete activity, item model, state owner, stateless screen, and row composable. Every add, toggle, and delete callback is included.
15 complete text files. Copy the selected file with the code panel button, or download the full project above.
package com.mastercoding.phase2
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.Button
import androidx.compose.material3.Checkbox
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.unit.dp
data class PackingItem(val id: Int, val name: String, val packed: Boolean = false)
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
MaterialTheme {
Scaffold { innerPadding ->
PackingListRoute(Modifier.padding(innerPadding).consumeWindowInsets(innerPadding))
}
}
}
}
}
@Composable
fun PackingListRoute(modifier: Modifier = Modifier) {
// These records are intentionally in memory: activity recreation starts a new list.
var items by remember { mutableStateOf(emptyList<PackingItem>()) }
var draft by remember { mutableStateOf("") }
var nextId by remember { mutableIntStateOf(1) }
PackingListScreen(
items = items,
draft = draft,
onDraftChange = { draft = it },
onAdd = {
val name = draft.trim()
if (name.isNotEmpty()) {
items = items + PackingItem(nextId, name)
nextId += 1
draft = ""
}
},
onToggle = { id ->
items = items.map { if (it.id == id) it.copy(packed = !it.packed) else it }
},
onDelete = { id -> items = items.filterNot { it.id == id } },
modifier = modifier
)
}
@Composable
fun PackingListScreen(
items: List<PackingItem>,
draft: String,
onDraftChange: (String) -> Unit,
onAdd: () -> Unit,
onToggle: (Int) -> Unit,
onDelete: (Int) -> Unit,
modifier: Modifier = Modifier
) {
// English labels are teaching text; use string resources for a localized app.
LazyColumn(
modifier = modifier.fillMaxSize().imePadding().padding(horizontal = 20.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
item(key = "heading") {
Column(verticalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier.padding(top = 20.dp)) {
Text("Packing Checklist", style = MaterialTheme.typography.headlineLarge)
Text("Practice adding, checking, and deleting items. This list is kept in memory.")
OutlinedTextField(
value = draft,
onValueChange = onDraftChange,
label = { Text("Item name") },
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
Button(onClick = onAdd, enabled = draft.isNotBlank()) { Text("Add item") }
Text("${items.count { it.packed }} of ${items.size} items packed")
}
}
if (items.isEmpty()) {
item(key = "empty") { Text("Your list is empty. Add your first item above.") }
}
items(items = items, key = { it.id }) { item ->
PackingItemRow(item = item, onToggle = { onToggle(item.id) }, onDelete = { onDelete(item.id) })
}
item(key = "footer") { Text("Activity recreation starts a fresh list.", Modifier.padding(bottom = 24.dp)) }
}
}
@Composable
fun PackingItemRow(item: PackingItem, onToggle: () -> Unit, onDelete: () -> Unit) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Checkbox(
checked = item.packed,
onCheckedChange = { onToggle() },
modifier = Modifier.semantics { contentDescription = "Packed: ${item.name}" }
)
Text(
text = item.name,
modifier = Modifier.weight(1f),
textDecoration = if (item.packed) TextDecoration.LineThrough else TextDecoration.None
)
TextButton(
onClick = onDelete,
modifier = Modifier.semantics { contentDescription = "Delete ${item.name}" }
) { Text("Delete") }
}
}Definition of done
Check an item only after demonstrating it. Progress is saved in this browser.
Try the reference solution yourself
Run each action and compare the result. Record the device, action and observed outcome. For the testing phase, run the included unit tests and the Compose test separately: a JVM test does not prove on-device UI behavior.
| Action | Expected result |
|---|---|
| Enter only spaces. | Add item stays disabled and no blank record is created. |
| Add a name with surrounding spaces. | The stored/displayed name is trimmed and the input clears. |
| Add Socks twice; check only the second row. | Two independent rows remain, and the packed total is 1 of 2. |
| Delete the first of the two Socks rows. | The second row stays checked because identity comes from its ID. |
| Delete every row. | The empty state appears and the packed total is 0 of 0. |
| Open the keyboard in landscape or with a larger font. | The list can scroll so the item input and Add item button remain reachable above the keyboard. |
| Rotate after adding an item. | The list clears, matching the explicitly documented in-memory lifetime. |
Common mistakes and repairs
- Using item.name or a list index as the row key fails when names repeat or positions change.
- Mutating an ordinary list in place can bypass the intended observable state update; assign a new immutable list.
- Keeping the packed total as separate mutable state introduces unnecessary synchronization.
- remember is not persistence. This starter intentionally resets on activity recreation; do not promise saved packing records.
- Button validation alone is not the state boundary; onAdd also rejects the trimmed empty name.
Assessment rubric
| Criterion | Points | Evidence |
|---|---|---|
| Core behavior | 40 | All acceptance scenarios produce their expected results. |
| Clear state ownership | 30 | Trace one event from its callback to the displayed result and identify the owner of each value. |
| Boundary cases | 20 | Verify the listed invalid, empty, and restoration cases. |
| Readable implementation | 10 | Use descriptive names and small functions; describe any known limitation. |
Total: 100 points. Demonstrate the working app, trace one event through the source, and verify a boundary case.
Optional extensions after the core works
- Add Uncheck all with map and copy.
- Display the remaining count as another derived value.
- In the persistence phase, replace in-memory records with a Room-backed repository.
- Move inline English labels to Android string resources.
After the intervening lessons, continue with Offline Lesson Catalog.
For deeper study, use the official Compose course and Android architecture guide.