Your assignment: Shopping Checklist
Build an editable shopping list with stable row identities, validation, purchase tracking, and lifecycle-aware state from a ViewModel. Learn why rotation retention and permanent storage are different requirements.
Intermediate · 3–4 hours of building
Before you begin
- Kotlin data classes, List, map, filterNot, nullable values, and copy
- Compose input fields, callbacks, and conditional rendering from Projects 1–6
- An introductory understanding of ViewModel and observable state; this lesson walks through StateFlow usage
By the end, you can…
- Represent shopping items with stable IDs and read-only properties
- Expose a single ShoppingUiState through a read-only StateFlow
- Add, toggle, and delete by creating new state values instead of mutating an unobserved list
- Display a bounded LazyColumn with stable keys and lifecycle-aware collection
- Distinguish state that survives rotation from data stored permanently in a database
Required features
- Add an item using a nonblank name and a positive whole-number quantity representable as Int.
- Show all items in a LazyColumn, with name, quantity, a purchased checkbox, and a labeled Delete action.
- Give every item a stable ID so duplicate names can be checked or deleted independently.
- Display the number of remaining item rows and show an empty-state message when the list is empty.
- Retain list contents, purchase states, input, and validation state during rotation using an activity-scoped ViewModel.
- Keep this version in memory. It may survive normal backgrounding, but it must not claim to persist after force-stop or a new app process. Add permanent storage in Project 10.
Set up the project and run the reference app
- Open: Extract the ZIP. In Android Studio choose Open, then select the project folder containing
settings.gradle.kts. Open that folder rather than just a Kotlin file. - Prepare: Use Android Studio’s bundled JDK 17 or 21 in Gradle settings. In SDK Manager install Android SDK Platform 36 and any Build Tools requested during sync.
- Sync: Allow Gradle to download the pinned dependencies. Initial setup requires internet. The app runs offline and needs no backend, login or API key.
- Run: In Device Manager create an emulator with API 23 or newer, or connect a device with USB debugging. Select the
apprun configuration and press Run. - Observe: Try the normal and boundary scenarios from Test the app. Record what changes after each action. Use Logcat to investigate a crash instead of changing several files at once.
- Practice: Copy the extracted folder to a working folder. Keep the build files, manifest, resources and
CommonUi.kt. Rebuild the application files following the steps below. Attempt each task before consulting its reference.
.\gradlew.bat :app:assembleDebug :app:testDebugUnitTestKeep the toolchain consistent
TOOLCHAIN.md for setup details.Understand the topics before coding
For each topic, connect the Kotlin concept to a visible behavior. Identify the value the screen reads and the event that can change that value.
Identity versus position
An item ID describes which item an event targets. A list index only describes its current position and can change after deletion. This app assigns a new Long ID on addition and passes that ID to toggle and delete. Two rows named Milk are therefore independent, even when one is removed or rows move.
Observable state and immutable updates
ShoppingUiState combines the form, items, and error into one value. Each item has read-only properties. Actions replace state with copy, append with +, toggle with map, or delete with filterNot. The original list and item values stay unchanged, and StateFlow can deliver the new state to the screen.
ViewModel and lifecycle-aware collection
ShoppingViewModel owns the state for this activity, and the Compose screen obtains it with viewModel(). collectAsStateWithLifecycle observes its StateFlow while the screen is active. Rotation recreates the UI but keeps the same ViewModel. A new process creates a fresh ViewModel and an empty list because this lesson intentionally has no database.
Bounded lazy layout
The outer Column fills the available screen and gives the LazyColumn its remaining height with weight(1f). The form and rows belong to that one scrolling list, making them reachable with a keyboard open or on a short screen. Stable item keys are the item IDs; form and summary rows use distinct String keys.
Derived values and validation policy
remainingCount is calculated from purchased flags instead of incremented manually in several callbacks. Input is validated before allocating an ID or adding a row. Blank names, zero, negatives, decimals, and quantities outside the Int range produce a useful error rather than an invalid shopping item.
Insets and accessible actions
The layout applies safeDrawing insets and keyboard padding before its content. Checkbox descriptions include the item name and intended action, and Delete descriptions identify the affected row. Purchased items have a checked control and a line-through label, so their status does not rely on color alone.
Three different lifetimes
rememberSaveable restores small supported values after Activity recreation; a ViewModel retains screen state during configuration changes. Neither replaces permanent storage. The notes project uses Room for records that must remain after closing and reopening the app.Build the app step by step
Work in this order. The code panels are focused excerpts from the complete files below. They may begin or end inside a function: use the full source browser when copying a file, including its imports. After every step, run the app and check the stated result.
Step 1. Run the starter structure and inspect its responsibilities
Your task: Open the extracted project folder containing settings.gradle.kts in Android Studio, sync, and run app on an API 23 or newer emulator. Locate MainActivity.kt, ShoppingRules.kt, ShoppingViewModel.kt, and strings.xml. Observe the empty-list message and read the in-memory storage note before adding data.
Open: app/src/main/java/com/mastercoding/miniprojects/shopping/MainActivity.kt
How it works: The screen is hosted in ComponentActivity and styled with the supplied MiniTheme. This project uses its own bounded scrolling layout because a LazyColumn should not be placed inside the shared vertically scrolling MiniScreen. The rules and ViewModel will be introduced separately from that layout.
import androidx.lifecycle.viewmodel.compose.viewModel
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent { MiniTheme { ShoppingScreen() } }
}
}
@Composable
private fun ShoppingScreen(model: ShoppingViewModel = viewModel()) {
val state by model.uiState.collectAsStateWithLifecycle()
Surface(modifier = Modifier.fillMaxSize()) {
Column(
modifier = Modifier.fillMaxSize()
.windowInsetsPadding(WindowInsets.safeDrawing)
.imePadding()
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Text(stringResource(R.string.screen_title), style = MaterialTheme.typography.headlineMedium)
// One bounded lazy list owns all scrolling, including the form on small screens.
LazyColumn(
modifier = Modifier.weight(1f).fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {Stop and check
Step 2. Define items, validation, and stable identity
Your task: Open ShoppingRules.kt. Identify the purpose of id, name, quantity, and purchased in ShoppingItem. Trace validation for a blank name, quantity 0, quantity 1.5, and a valid item. Study togglePurchased and deleteShoppingItem with two items that share the same name but have IDs 1 and 2.
Open: app/src/main/java/com/mastercoding/miniprojects/shopping/ShoppingRules.kt
How it works: The validator returns a named error rather than UI text. map creates a changed copy only for the matching ID; filterNot removes only the matching ID. Because names and positions are not used as identity, duplicate names and row deletion cannot redirect an action to a different item.
package com.mastercoding.miniprojects.shopping
data class ShoppingItem(
val id: Long,
val name: String,
val quantity: Int,
val purchased: Boolean = false
)
enum class ShoppingInputError { NAME_REQUIRED, INVALID_QUANTITY }
fun validateShoppingInput(name: String, quantity: String): ShoppingInputError? = when {
name.isBlank() -> ShoppingInputError.NAME_REQUIRED
(quantity.trim().toIntOrNull() ?: 0) < 1 -> ShoppingInputError.INVALID_QUANTITY
else -> null
}
fun togglePurchased(items: List<ShoppingItem>, id: Long): List<ShoppingItem> =
items.map { if (it.id == id) it.copy(purchased = !it.purchased) else it }
fun deleteShoppingItem(items: List<ShoppingItem>, id: Long): List<ShoppingItem> =
items.filterNot { it.id == id }Stop and check
Step 3. Create one screen-state value and a read-only flow
Your task: Read ShoppingUiState and its remainingCount getter, then inspect ShoppingViewModel. Keep MutableStateFlow private and expose asStateFlow to the UI. Explain why the screen should call actions such as updateName and delete rather than assigning directly to the mutable state. Change the form text and watch the rendered values update.
Open: app/src/main/java/com/mastercoding/miniprojects/shopping/ShoppingViewModel.kt
How it works: The ViewModel is the single owner of screen updates. The UI receives the current value and sends events back, giving data a clear direction. remainingCount is derived from the actual items so it cannot drift away from the checkboxes after adding, toggling, or deleting.
/** In-memory state survives rotation, but it is not permanent database storage. */
class ShoppingViewModel : ViewModel() {
private val mutableUiState = MutableStateFlow(ShoppingUiState())
val uiState = mutableUiState.asStateFlow()
private var nextId = 1L
fun updateName(value: String) {
mutableUiState.update { it.copy(name = value, error = null) }
}
fun updateQuantity(value: String) {
mutableUiState.update { it.copy(quantity = value, error = null) }
}
fun addItem() {
val state = mutableUiState.value
val error = validateShoppingInput(state.name, state.quantity)
if (error != null) {
mutableUiState.update { it.copy(error = error) }
return
}
// UI events run on the main thread; IDs never depend on row positions.
val item = ShoppingItem(nextId++, state.name.trim(), state.quantity.trim().toInt())
mutableUiState.value = state.copy(
name = "", quantity = "1", items = state.items + item, error = nullStop and check
Step 4. Implement the add transition and validation feedback
Your task: Trace addItem from the current state snapshot through validation. Keep the early return for an error. For a valid form, create a ShoppingItem with nextId, a trimmed name, and the parsed quantity; then append it and reset the form. Add Milk with quantity 2, then add another Milk with quantity 1. Try invalid values before and after the valid additions.
Open: app/src/main/java/com/mastercoding/miniprojects/shopping/ShoppingViewModel.kt
How it works: Validation happens before parsing with toInt and before creating an item. The ID counter belongs to the ViewModel and is advanced only for successful additions. Form reset and list insertion happen in one new state value, preventing a partially reset screen from being shown.
}
fun addItem() {
val state = mutableUiState.value
val error = validateShoppingInput(state.name, state.quantity)
if (error != null) {
mutableUiState.update { it.copy(error = error) }
return
}
// UI events run on the main thread; IDs never depend on row positions.
val item = ShoppingItem(nextId++, state.name.trim(), state.quantity.trim().toInt())
mutableUiState.value = state.copy(
name = "", quantity = "1", items = state.items + item, error = null
)
}
fun toggle(id: Long) {
mutableUiState.update { it.copy(items = togglePurchased(it.items, id)) }
}
fun delete(id: Long) {
mutableUiState.update { it.copy(items = deleteShoppingItem(it.items, id)) }
}
}Stop and check
Step 5. Collect state and give the list a bounded viewport
Your task: Open ShoppingScreen. Obtain the activity ViewModel with viewModel and collect its state with collectAsStateWithLifecycle. Follow the fillMaxSize Column, safeDrawing insets, imePadding, and LazyColumn weight. Keep the form, summary, empty state, and shopping rows inside the one LazyColumn. Test landscape mode with the keyboard visible.
Open: app/src/main/java/com/mastercoding/miniprojects/shopping/MainActivity.kt
How it works: A scrolling list needs a finite height. The weighted list receives the space below the title, while placing the form inside it lets short screens scroll to every control. Lifecycle-aware collection connects StateFlow to Compose without manually starting an observer in each callback.
@Composable
private fun ShoppingScreen(model: ShoppingViewModel = viewModel()) {
val state by model.uiState.collectAsStateWithLifecycle()
Surface(modifier = Modifier.fillMaxSize()) {
Column(
modifier = Modifier.fillMaxSize()
.windowInsetsPadding(WindowInsets.safeDrawing)
.imePadding()
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Text(stringResource(R.string.screen_title), style = MaterialTheme.typography.headlineMedium)
// One bounded lazy list owns all scrolling, including the form on small screens.
LazyColumn(
modifier = Modifier.weight(1f).fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
item(key = "instructions") { Text(stringResource(R.string.instructions)) }
item(key = "form") {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedTextField(
value = state.name,
onValueChange = model::updateName,
label = { Text(stringResource(R.string.item_name)) },
isError = state.error == ShoppingInputError.NAME_REQUIRED,
singleLine = true,Stop and check
Step 6. Render keyed rows and connect targeted actions
Your task: Find items(state.items, key = { it.id }). Follow each row callback to model.toggle(item.id) or model.delete(item.id). Inspect ShoppingItemCard and the checkbox and Delete descriptions. With two Milk rows present, check only the second, then delete only the first. Notice the remaining count and crossed-out text.
Open: app/src/main/java/com/mastercoding/miniprojects/shopping/MainActivity.kt
How it works: Stable keys let the lazy list track the same item when positions change. The row receives an immutable item plus action callbacks rather than the whole mutable store. Using the same ID in keys and actions aligns rendering identity with the rule that updates the data.
item(key = "empty") { Text(stringResource(R.string.empty_list)) }
}
items(state.items, key = { it.id }) { item ->
ShoppingItemCard(item, onToggle = { model.toggle(item.id) }, onDelete = { model.delete(item.id) })
}
}
}
}
}
@Composable
private fun ShoppingItemCard(item: ShoppingItem, onToggle: () -> Unit, onDelete: () -> Unit) {
val toggleDescription = stringResource(
if (item.purchased) R.string.mark_remaining else R.string.mark_purchased, item.name
)
val deleteDescription = stringResource(R.string.delete_description, item.name)
Card(modifier = Modifier.fillMaxWidth()) {
Row(
modifier = Modifier.fillMaxWidth().padding(8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp)
) {
Checkbox(
checked = item.purchased,
onCheckedChange = { onToggle() },
modifier = Modifier.semantics { contentDescription = toggleDescription }Stop and check
Step 7. Verify rotation and document the storage boundary
Your task: Add at least two items, check one, and type an unfinished third item. Rotate the emulator and confirm all of those values remain. Next force-stop the app through emulator Settings → Apps, reopen it, and observe the empty list. Write this distinction in your README; do not use normal backgrounding as proof that permanent storage exists.
Open: app/src/main/java/com/mastercoding/miniprojects/shopping/ShoppingViewModel.kt
How it works: The activity-scoped ViewModel survives a configuration change, but it holds ordinary memory. Force-stop or process recreation produces a new instance. Retaining a screen through rotation solves a different problem from keeping user records across launches; Project 10 adds the database responsibility.
/** In-memory state survives rotation, but it is not permanent database storage. */
class ShoppingViewModel : ViewModel() {
private val mutableUiState = MutableStateFlow(ShoppingUiState())
val uiState = mutableUiState.asStateFlow()
private var nextId = 1L
fun updateName(value: String) {
mutableUiState.update { it.copy(name = value, error = null) }
}
fun updateQuantity(value: String) {
mutableUiState.update { it.copy(quantity = value, error = null) }
}
fun addItem() {
val state = mutableUiState.value
val error = validateShoppingInput(state.name, state.quantity)
if (error != null) {
mutableUiState.update { it.copy(error = error) }
return
}
// UI events run on the main thread; IDs never depend on row positions.
val item = ShoppingItem(nextId++, state.name.trim(), state.quantity.trim().toInt())
mutableUiState.value = state.copy(
name = "", quantity = "1", items = state.items + item, error = null
)Stop and check
Step 8. Test list rules and submit your walkthrough
Your task: Run ShoppingRulesTest using its gutter icon or gradlew.bat :app:testDebugUnitTest. Read the duplicate-name test and the assertions that the original list is unchanged. Complete the manual checks below. Submit screenshots of empty, populated, and validation-error states plus a README explaining the event path from a checkbox to a new UI state.
Open: app/src/test/java/com/mastercoding/miniprojects/shopping/ShoppingRulesTest.kt
How it works: The tests target meaningful risks: invalid quantities, duplicate labels, accidental mutation, and deleting the wrong row. Device checks add the behavior those tests cannot see, including keyboard layout, lifecycle collection, and rotation.
}
@Test fun toggleTargetsStableIdEvenWhenNamesAreDuplicated() {
val original = listOf(ShoppingItem(1L, "Milk", 1), ShoppingItem(2L, "Milk", 2))
val updated = togglePurchased(original, 2L)
assertFalse(updated[0].purchased)
assertTrue(updated[1].purchased)
assertFalse(original[1].purchased)
}
@Test fun deleteDoesNotChangeOtherItemsOrOriginalList() {
val original = listOf(ShoppingItem(10L, "Rice", 1), ShoppingItem(11L, "Bread", 3))
val updated = deleteShoppingItem(original, 10L)
assertEquals(listOf(ShoppingItem(11L, "Bread", 3)), updated)
assertEquals(2, original.size)
assertTrue(deleteShoppingItem(updated, 11L).isEmpty())
}
}Stop and check
Complete source files
Every text file in the ZIP is available here, including Kotlin, resources, Gradle scripts and the student README. The binary Gradle wrapper JAR is included in the ZIP. Source excerpts and downloads are generated from these same files.
| Start here | Responsibility |
|---|---|
| MainActivity.kt | The launcher Activity installs the Compose UI with setContent. |
| CommonUi.kt | Material 3 theme and reusable screen container with scrolling and system insets. |
| Application Kotlin files | State, rules, navigation or persistence introduced in the guide. |
| app/src/main/res | Images, text resources, and the launch theme. |
| app/build.gradle.kts | SDK settings and the exact libraries used by this app. |
| README.md / TOOLCHAIN.md | Student instructions and setup, usable without the website. |
Android launcher Activity, setContent entry point and screen composition. Start tracing the app here.
21 complete text files. Copy the selected file with the code panel button, or download the full project above.
package com.mastercoding.miniprojects.shopping
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.WindowInsets
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.layout.safeDrawing
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.Checkbox
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.LiveRegionMode
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.liveRegion
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent { MiniTheme { ShoppingScreen() } }
}
}
@Composable
private fun ShoppingScreen(model: ShoppingViewModel = viewModel()) {
val state by model.uiState.collectAsStateWithLifecycle()
Surface(modifier = Modifier.fillMaxSize()) {
Column(
modifier = Modifier.fillMaxSize()
.windowInsetsPadding(WindowInsets.safeDrawing)
.imePadding()
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Text(stringResource(R.string.screen_title), style = MaterialTheme.typography.headlineMedium)
// One bounded lazy list owns all scrolling, including the form on small screens.
LazyColumn(
modifier = Modifier.weight(1f).fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
item(key = "instructions") { Text(stringResource(R.string.instructions)) }
item(key = "form") {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedTextField(
value = state.name,
onValueChange = model::updateName,
label = { Text(stringResource(R.string.item_name)) },
isError = state.error == ShoppingInputError.NAME_REQUIRED,
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
OutlinedTextField(
value = state.quantity,
onValueChange = model::updateQuantity,
label = { Text(stringResource(R.string.quantity)) },
isError = state.error == ShoppingInputError.INVALID_QUANTITY,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
state.error?.let { error ->
Text(
stringResource(when (error) {
ShoppingInputError.NAME_REQUIRED -> R.string.name_error
ShoppingInputError.INVALID_QUANTITY -> R.string.quantity_error
}),
color = MaterialTheme.colorScheme.error,
modifier = Modifier.semantics { liveRegion = LiveRegionMode.Polite }
)
}
Button(onClick = model::addItem, modifier = Modifier.fillMaxWidth()) {
Text(stringResource(R.string.add_item))
}
}
}
item(key = "remaining") {
Text(
stringResource(R.string.remaining, state.remainingCount, state.items.size),
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.semantics { liveRegion = LiveRegionMode.Polite }
)
}
if (state.items.isEmpty()) {
item(key = "empty") { Text(stringResource(R.string.empty_list)) }
}
items(state.items, key = { it.id }) { item ->
ShoppingItemCard(item, onToggle = { model.toggle(item.id) }, onDelete = { model.delete(item.id) })
}
}
}
}
}
@Composable
private fun ShoppingItemCard(item: ShoppingItem, onToggle: () -> Unit, onDelete: () -> Unit) {
val toggleDescription = stringResource(
if (item.purchased) R.string.mark_remaining else R.string.mark_purchased, item.name
)
val deleteDescription = stringResource(R.string.delete_description, item.name)
Card(modifier = Modifier.fillMaxWidth()) {
Row(
modifier = Modifier.fillMaxWidth().padding(8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp)
) {
Checkbox(
checked = item.purchased,
onCheckedChange = { onToggle() },
modifier = Modifier.semantics { contentDescription = toggleDescription }
)
Column(modifier = Modifier.weight(1f)) {
Text(
item.name,
style = MaterialTheme.typography.titleMedium,
textDecoration = if (item.purchased) TextDecoration.LineThrough else TextDecoration.None
)
Text(stringResource(R.string.item_quantity, item.quantity))
}
TextButton(
onClick = onDelete,
modifier = Modifier.semantics { contentDescription = deleteDescription }
) { Text(stringResource(R.string.delete)) }
}
}
}Test the app with concrete scenarios
Run these scenarios on your device or emulator. Write down the actual result beside the expected result. A successful Gradle build checks compilation; it does not demonstrate tapping, accessibility, keyboard behavior or rotation.
| Do this | Expected result |
|---|---|
| Submit a blank name and then quantities 0, -1, 1.5, abc, and 2147483648 with a valid name. | Validation explains the problem and adds no row. Values beyond Int range are rejected safely. |
| Add Milk twice with different quantities. | Two separate rows appear, each with its own quantity and ID; the form clears after each valid addition. |
| Check one of the two Milk rows, delete the other, and uncheck the survivor. | Only the targeted row changes each time. Remaining count matches unpurchased item rows. |
| Delete every item. | The count becomes zero and the helpful empty-list message returns. |
| Populate the list, check one row, leave a draft in the fields, and rotate. | Items, checkmarks, draft input, and any validation state are retained through the same ViewModel. |
| Use landscape mode, a larger font size, and the open keyboard. | The one bounded list scrolls; form controls and Delete actions remain reachable without an infinite-height crash. |
| Force-stop and reopen the app, then run ShoppingRulesTest. | The list resets as documented for this in-memory version, and the validation and stable-ID tests all pass. |
Your completion checklist
Check an item only after demonstrating it. Progress is saved in this browser.
Troubleshooting: understand the cause
A checkbox changes but the UI does not update consistently.
Cause: A plain mutable list or an item property was changed in place without emitting new observable state.
Repair: Use data-class copy and a new list, then assign a new ShoppingUiState through StateFlow.
Checking or deleting one duplicate name changes another row.
Cause: Callbacks target the label or list position instead of stable identity.
Repair: Assign each item its own Long ID and use that ID for lazy keys, toggle, and delete.
The app throws a vertically scrollable component measured with infinite height error.
Cause: LazyColumn is nested in an unbounded vertically scrolling Column.
Repair: Keep the supplied finite-height Column and weight(1f) list; put the form inside the same LazyColumn.
Rows disappear after rotation.
Cause: The list is created inside an ordinary composable local variable or a newly constructed ViewModel on each render.
Repair: Obtain the ViewModel with viewModel() and collect its existing StateFlow.
Students expect the list to survive force-stop.
Cause: Rotation retention was mistaken for permanent storage.
Repair: Document the in-memory limitation and add a Room repository when implementing the persistence extension.
Submit your app and explain your decisions
- Submit the Android Studio project without build caches,
.gradleor machine-specificlocal.properties. - Include two screenshots demonstrating normal behavior and an edge case.
- Add a short README recording the device/API version, actions, observed outcomes, and any known limitation.
- Answer the questions below using function and state names from your own implementation.
- Why is an item name unsuitable as a unique key, and why is its current index unsuitable for long-lived identity?
- What would go wrong if remainingCount were manually increased and decreased in every callback?
- Why does a ViewModel solve rotation retention but still require a database for permanent shopping data?
| Criterion | Points | What to demonstrate |
|---|---|---|
| Core behavior | 40 | All required actions give the expected result. |
| Validation and lifecycle | 20 | Invalid inputs, empty states, rotation and the project's persistence rules work. |
| Interface and accessibility | 20 | Readable contrast, labeled inputs, meaningful image descriptions, scrollable content and usable touch targets. |
| Code and explanation | 20 | Small named functions, clear state ownership and a correct explanation of one event-to-result trace. |
Extra challenges after the core works
- Add All, Remaining, and Purchased filters. Derive a visible list from the original items rather than deleting hidden rows.
- Add an Undo snackbar for deletion. Store the removed item and its position so the same stable ID can be restored.
- Add inline quantity editing with the same validation policy.
- Connect the list to Room using the persistence patterns from Project 10 and compare rotation, process recreation, and force-stop behavior.
Choose one extension, write its acceptance check first, then implement it without breaking the original scenarios.