Phase 2: Shopping List
Build a single-screen list that adds trimmed item names, checks purchases, and deletes rows. Keep records in memory so the lecture can focus on data classes, immutable updates, stable IDs, and state hoisting. Activity recreation intentionally clears the list.
Guided workshop: 2–3 hours
Before you start
- State Management & State Hoisting
- Handling User Input
- Lists with LazyColumn & LazyRow
Application structure and responsibilities
- One screen with one main task. Finish the small working version before adding optional features.
- All required source files, Gradle configuration, manifest and resources are included in the downloadable project.
Concepts to explain: Data classes · Immutable lists · remember state lifetime · State hoisting · Callbacks · LazyColumn stable keys · Input validation · Derived totals
See the concept, then explain the implementation
Follow one concrete interaction through the app. Watch which responsibility is active, inspect the state snapshot, and pause to predict what happens next. Open the Kotlin excerpt to connect each visual frame to the implementation.
Follow a row event back to its owner
Stable identity → callback → immutable update → keyed list
Displays one item and emits an event
Carries the selected item ID upward
Owns and replaces the item list
Matches rows by stable ID
Separate identity from the label
A name is display content, not identity: two different shopping entries may both say Bread. IDs are allocated when an item is added. Our example starts after these two records already exist.
- row 1
- ID 2 · Bread · unchecked
- row 2
- ID 3 · Bread · unchecked
- purchased
- 0 of 2
Is the name Bread enough to identify which row was checked?
No. Both rows have that name. The callback must identify ID 3 to update only the selected record.
Connect this frame to the Kotlin source
app/src/main/java/com/mastercoding/phase2/MainActivity.kt
data class ShoppingItem(val id: Int, val name: String, val purchased: Boolean = false)Misconception: A row index is a permanent item ID.
Indices change when records are inserted or removed. Allocate an ID once and keep it with the record.
Misconception: Each row should store another purchased flag.
The list owns the flag. The row receives its value and a callback, so the summary and checkbox read the same data.
Misconception: LazyColumn keys save the shopping list.
Keys identify rows in the composition. Durable storage is a separate concern.
Pause, step, or scrub through the interaction. This teaching model explains the included source; it does not execute Android or run tests in the browser. Verify the behavior with the downloaded app.
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
Shopping List: build it one step at a time
Follow the implementation in order. Predict the result of each step, then explain the change in your own words.
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.
Step-by-step implementation and lecture solution
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. Pause at the prediction prompt before running the next behavior.
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 ShoppingItem: id identifies a record, name is its label, and purchased 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 ShoppingItem(val id: Int, val name: String, val purchased: Boolean = false)
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
MaterialTheme {
Scaffold { innerPadding ->
ShoppingListRoute(Modifier.padding(innerPadding).consumeWindowInsets(innerPadding))
}
}
}
}
}
@ComposableExpected behavior
Pause and explain this step
Draw two Milk records with different IDs. Ask which field should identify a row when one is deleted.
Step 2: Keep changing values in the route
ShoppingListRoute 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. ShoppingListScreen 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 ShoppingListRoute(modifier: Modifier = Modifier) {
// These records are intentionally in memory: activity recreation starts a new list.
var items by remember { mutableStateOf(emptyList<ShoppingItem>()) }
var draft by remember { mutableStateOf("") }
var nextId by remember { mutableIntStateOf(1) }
ShoppingListScreen(
items = items,
draft = draft,
onDraftChange = { draft = it },
onAdd = {
val name = draft.trim()
if (name.isNotEmpty()) {
items = items + ShoppingItem(nextId, name)
nextId += 1
draft = ""
}Expected behavior
Pause and explain this step
Draw arrows for values down and events up. Compare remember here with rememberSaveable in the previous project and state the lifetime limitation explicitly.
Step 3: Validate and add one record
onAdd trims the draft, checks that the result is not empty, appends a ShoppingItem 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 + ShoppingItem(nextId, name)
nextId += 1
draft = ""
}
},
onToggle = { id ->
items = items.map { if (it.id == id) it.copy(purchased = !it.purchased) else it }
},
onDelete = { id -> items = items.filterNot { it.id == id } },
modifier = modifier
)
}
@Composable
fun ShoppingListScreen(Expected behavior
Pause and explain this step
Trace draft, name, nextId, and items on the board for two additions. Show why the second Milk receives a different ID.
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(purchased = !it.purchased) else it }
},
onDelete = { id -> items = items.filterNot { it.id == id } },
modifier = modifier
)
}
@Composable
fun ShoppingListScreen(
items: List<ShoppingItem>,
draft: String,
onDraftChange: (String) -> Unit,
onAdd: () -> Unit,
onToggle: (Int) -> Unit,
onDelete: (Int) -> Unit,
modifier: Modifier = ModifierExpected behavior
Pause and explain this step
Write the list before and after map and filterNot. Ask students which values changed and which IDs stayed the same.
Step 5: Render a stateless list and derive the total
ShoppingListScreen displays its parameters. LazyColumn renders a header, optional empty message, and keyed item rows. key = { it.id } ties composition identity to the record. The purchased 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 ->
ShoppingItemRow(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 ShoppingItemRow(item: ShoppingItem, onToggle: () -> Unit, onDelete: () -> Unit) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Checkbox(
checked = item.purchased,
onCheckedChange = { onToggle() },Expected behavior
Pause and explain this step
Ask students to predict the total after deleting a checked item. Explain why no total update exists in the delete handler.
Step 6: Connect row callbacks and verify the scope
ShoppingItemRow 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 ShoppingItemRow(item: ShoppingItem, onToggle: () -> Unit, onDelete: () -> Unit) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Checkbox(
checked = item.purchased,
onCheckedChange = { onToggle() },
modifier = Modifier.semantics { contentDescription = "Purchased: ${item.name}" }
)
Text(
text = item.name,
modifier = Modifier.weight(1f),
textDecoration = if (item.purchased) TextDecoration.LineThrough else TextDecoration.None
)
TextButton(Expected behavior
Pause and explain this step
Invite students to locate the state mutation when a checkbox is tapped. It is in the route callback, while the row only reports an event.
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 | What to explain |
|---|---|
| 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 lecture 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 ShoppingItem(val id: Int, val name: String, val purchased: Boolean = false)
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
MaterialTheme {
Scaffold { innerPadding ->
ShoppingListRoute(Modifier.padding(innerPadding).consumeWindowInsets(innerPadding))
}
}
}
}
}
@Composable
fun ShoppingListRoute(modifier: Modifier = Modifier) {
// These records are intentionally in memory: activity recreation starts a new list.
var items by remember { mutableStateOf(emptyList<ShoppingItem>()) }
var draft by remember { mutableStateOf("") }
var nextId by remember { mutableIntStateOf(1) }
ShoppingListScreen(
items = items,
draft = draft,
onDraftChange = { draft = it },
onAdd = {
val name = draft.trim()
if (name.isNotEmpty()) {
items = items + ShoppingItem(nextId, name)
nextId += 1
draft = ""
}
},
onToggle = { id ->
items = items.map { if (it.id == id) it.copy(purchased = !it.purchased) else it }
},
onDelete = { id -> items = items.filterNot { it.id == id } },
modifier = modifier
)
}
@Composable
fun ShoppingListScreen(
items: List<ShoppingItem>,
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("Shopping List", 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.purchased }} of ${items.size} items purchased")
}
}
if (items.isEmpty()) {
item(key = "empty") { Text("Your list is empty. Add your first item above.") }
}
items(items = items, key = { it.id }) { item ->
ShoppingItemRow(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 ShoppingItemRow(item: ShoppingItem, onToggle: () -> Unit, onDelete: () -> Unit) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Checkbox(
checked = item.purchased,
onCheckedChange = { onToggle() },
modifier = Modifier.semantics { contentDescription = "Purchased: ${item.name}" }
)
Text(
text = item.name,
modifier = Modifier.weight(1f),
textDecoration = if (item.purchased) 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 Milk twice; check only the second row. | Two independent rows remain, and the purchased total is 1 of 2. |
| Delete the first of the two Milk rows. | The second row stays checked because identity comes from its ID. |
| Delete every row. | The empty state appears and the purchased 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 purchased total as separate mutable state introduces unnecessary synchronization.
- remember is not persistence. This starter intentionally resets on activity recreation; do not promise saved shopping records.
- Button validation alone is not the state boundary; onAdd also rejects the trimmed empty name.
Instructor lecture plan
| Minutes | Classroom activity |
|---|---|
| 0–10 | Run the finished app first. Ask students to describe its input, state and expected output. |
| 10–30 | Walk through the source files in the solution order. Pause at each named function and predict the next change. |
| 30–50 | Rebuild the main feature together using the guided steps; compare behavior with the complete reference source. |
| 50–65 | Pairs reproduce an edge case and explain one callback or data-flow path to the class. |
Instructor discussion and answer notes
Draw arrows for values down and events up. Compare remember here with rememberSaveable in the previous project and state the lifetime limitation explicitly.
ShoppingListRoute 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. ShoppingListScreen receives values and callbacks, allowing the screen to display state without deciding where it lives. Expected result: Typing updates draft and enables Add for nonblank input. Rotation resets the in-memory example.
Trace draft, name, nextId, and items on the board for two additions. Show why the second Milk receives a different ID.
onAdd trims the draft, checks that the result is not empty, appends a ShoppingItem 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. Expected result: Adding a padded name such as two spaces, Milk, and two spaces creates the label Milk and clears the text field.
Write the list before and after map and filterNot. Ask students which values changed and which IDs stayed the same.
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. Expected result: Checking the second of two equal names affects only the second row. Deleting the first preserves the second row and its checked value.
Assessment rubric
| Criterion | Points | Evidence |
|---|---|---|
| Working core behavior | 40 | Demonstrate the project's acceptance checklist and expected scenarios. |
| Understand the implementation | 30 | Explain the named state owner, event callback and source files without reading a copied script. |
| Edge cases and verification | 20 | Reproduce the listed boundary/error cases and record the observed outcome. |
| Clear classroom demonstration | 10 | Show the app, trace one action and describe one small extension. |
Total: 100 points. Ask students to show the working app, explain one complete event/data path, reproduce an edge case, and make one small change themselves. Keep any limitations explicit.
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 Two-Screen Recipe Book.
For deeper study, use the official Compose course and Android architecture guide.