Phase 2 · Building Interactive UIsModule 17~30 min read

Phase 2 Capstone · 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.

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

This capstone has a complete reference solution. Run it first, explain one small feature at a time, then let students rebuild it. The core app works offline; advanced additions are optional exercises.

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.

Phase 2 Interactive concept studio

Follow a row event back to its owner

Stable identity → callback → immutable update → keyed list

Follow this interactionTwo rows are both named Bread, with IDs 2 and 3. The student checks only the row with ID 3.
01Responsibility
Item row

Displays one item and emits an event

02Responsibility
Screen callback

Carries the selected item ID upward

03Active now
List route

Owns and replaces the item list

04Responsibility
LazyColumn

Matches rows by stable ID

List route
Observe the current stateNo transfer in this frame
State snapshot below

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.

State snapshot
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?

Connect this frame to the Kotlin source

app/src/main/java/com/mastercoding/phase2/MainActivity.kt

MainActivity.kt
data class ShoppingItem(val id: Int, val name: String, val purchased: Boolean = false)
A focused source excerpt. The complete file and runnable project are in the source section below.
Open the complete source section →
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.

  1. Extract the ZIP. In Android Studio, open the project folder containing settings.gradle.kts.
  2. Use JDK 17 or 21 and install Android SDK Platform 36 plus the Build Tools requested by Android Studio.
  3. Allow the first Gradle sync to download dependencies. No backend account or API key is needed.
  4. Select an emulator or device running API 23 or newer, then run the app configuration.
  5. Start with the expected results below. Open README.md and TOOLCHAIN.md for the exact project instructions.
PowerShell
.\gradlew.bat :app:assembleDebug :app:testDebugUnitTest
Run inside the extracted project. macOS/Linux: chmod +x gradlew, then ./gradlew :app:assembleDebug :app:testDebugUnitTest. UI tests need a running emulator: :app:connectedDebugAndroidTest.

Keep the first version small

Run the reference app, trace one input through the code, and reproduce its behavior before changing it. The short English labels are teaching literals; localization is an optional follow-up exercise.

Implementation roadmap

Android concept labInteractive walkthrough

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.

Lesson sequence1 / 6
Step 1 of 6

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.

Up nextKeep changing values in the route
A teaching simulation; diagrams illustrate the concept. Run the Kotlin examples in Android Studio.

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

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))
                }
            }
        }
    }
}

@Composable
Focused excerpt, lines 35–53. Select this file in Complete source code for every line.

Expected behavior

The starting screen has no records, shows an empty state, and has a disabled Add item button.
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

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 = ""
            }
Focused excerpt, lines 52–70. Select this file in Complete source code for every line.

Expected behavior

Typing updates draft and enables Add for nonblank input. Rotation resets the in-memory example.
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

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(
Focused excerpt, lines 63–81. Select this file in Complete source code for every line.

Expected behavior

Adding a padded name such as two spaces, Milk, and two spaces creates the label Milk and clears the text field.
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

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 = Modifier
Focused excerpt, lines 70–88. Select this file in Complete source code for every line.

Expected behavior

Checking the second of two equal names affects only the second row. Deleting the first preserves the second row and its checked value.
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

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() },
Focused excerpt, lines 111–129. Select this file in Complete source code for every line.

Expected behavior

The purchased total reflects the current checked rows, and the empty state returns when the last row is deleted.
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

MainActivity.kt

@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(
Focused excerpt, lines 119–137. Select this file in Complete source code for every line.

Expected behavior

Each row responds independently, blank input is rejected, and activity recreation starts a documented fresh list.
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.

FileWhat to explain
app/src/main/java/com/mastercoding/phase2/MainActivity.ktComplete activity, item model, state owner, stateless screen, and row composable. Every add, toggle, and delete callback is included.
README.mdRun 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.

MainActivity.kt
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

Project acceptance checklist0 / 5 verified

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.

ActionExpected 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

MinutesClassroom activity
0–10Run the finished app first. Ask students to describe its input, state and expected output.
10–30Walk through the source files in the solution order. Pause at each named function and predict the next change.
30–50Rebuild the main feature together using the guided steps; compare behavior with the complete reference source.
50–65Pairs 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

CriterionPointsEvidence
Working core behavior40Demonstrate the project's acceptance checklist and expected scenarios.
Understand the implementation30Explain the named state owner, event callback and source files without reading a copied script.
Edge cases and verification20Reproduce the listed boundary/error cases and record the observed outcome.
Clear classroom demonstration10Show 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.