Phase 3: Two-Screen Recipe Book
Open one of three offline recipes, read its ingredients and steps, and mark it as a favorite. Two screens teach typed navigation and shared state without a server or database.
Guided workshop: 2–3 hours
Before you start
- Navigation in Compose
- ViewModel & UI State
Application structure and responsibilities
- Two screens: one list and a focused detail screen. Use the visible Back action or system Back to return.
- All required source files, Gradle configuration, manifest and resources are included in the downloadable project.
Concepts to explain: Typed Navigation Compose routes · Pass a recipe ID · Nullable lookups · ViewModel and StateFlow · Lifecycle-aware collection · Back navigation
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.
Navigate by ID, then update a favorite
Typed route → catalog lookup → shared ViewModel → both screens
Carries recipeId between destinations
Looks up a recipe by ID
Owns favorite IDs in StateFlow
Render recipe data and favorite state
Send a small navigation argument
The list callback sends recipe ID 2 in a typed RecipeDetail route. It does not copy the full recipe into navigation arguments. The destination will look up the record from the shared catalog.
- selected recipe
- Banana Yogurt Bowl
- route argument
- recipeId = 2
- favorite IDs
- {}
Does this route need the recipe title, ingredient list, and instructions?
No. It carries recipeId = 2. The destination resolves the complete recipe from RecipeBook.
Connect this frame to the Kotlin source
app/src/main/java/com/mastercoding/phase3/MainActivity.kt
RecipeListScreen(
favoriteIds = state.favoriteIds,
onOpenRecipe = { id -> navController.navigate(RecipeDetail(id)) }
)Misconception: Navigation must carry the entire recipe object.
Pass the small recipe ID, then resolve the record at the destination.
Misconception: The route owns the favorite flag.
The route identifies the recipe. The shared ViewModel owns the favorite set.
Misconception: Surviving rotation proves favorites are stored permanently.
A retained ViewModel is an in-memory lifetime mechanism. This sample does not persist favorites across process death.
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
Two-Screen Recipe Book: 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.
Model three recipes before drawing the screens
IDs 1, 2, and 3 resolve to Tomato Toast, Banana Yogurt Bowl, and Cucumber Salad; another ID returns null.
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: Model three recipes before drawing the screens
Recipe is one record with an integer ID, a title, a duration, ingredients, and instructions. RecipeBook holds three ready-to-use records. Its find function returns Recipe? so callers must handle a missing ID.
Open: app/src/main/java/com/mastercoding/phase3/RecipeViewModel.kt
import kotlinx.coroutines.flow.update
data class Recipe(
val id: Int,
val title: String,
val minutes: Int,
val ingredients: List<String>,
val instructions: List<String>
)
object RecipeBook {
val recipes = listOf(
Recipe(
id = 1,
title = "Tomato Toast",
minutes = 5,
ingredients = listOf("1 slice of bread", "1 tomato", "1 teaspoon olive oil"),
instructions = listOf("Toast the bread.", "Slice the tomato.", "Place tomato on toast and drizzle with oil.")
),Expected behavior
Pause and explain this step
Draw a record and point to its ID. Ask why changing the title should not change the identity of a recipe. Trace RecipeBook.find(2) by hand.
Step 2: Render the list and report card taps
RecipeListScreen iterates over the three records and displays a card for each. A click invokes onOpenRecipe(recipe.id). This callback keeps screen rendering independent from the navigation controller.
Open: app/src/main/java/com/mastercoding/phase3/MainActivity.kt
@Composable
fun RecipeListScreen(favoriteIds: Set<Int>, onOpenRecipe: (Int) -> Unit) {
Column(
modifier = Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(20.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Text("My Recipe Book", style = MaterialTheme.typography.headlineMedium)
Text("Choose one of three quick recipes. Favorites stay while the app is open.")
RecipeBook.recipes.forEach { recipe ->
Card(onClick = { onOpenRecipe(recipe.id) }, modifier = Modifier.fillMaxWidth()) {
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(recipe.title, style = MaterialTheme.typography.titleLarge)
Text("${recipe.minutes} minutes")
if (recipe.id in favoriteIds) Text("Favorite")
}
}
}
}Expected behavior
Pause and explain this step
Have a student trace the second card's click. The screen reports an event; RecipeApp decides where to go.
Step 3: Connect two typed destinations
RecipeList is a serializable object because it has no arguments. RecipeDetail is a serializable data class with recipeId. NavHost starts at RecipeList, navigate receives RecipeDetail(id), and toRoute reads the typed argument at the destination. Only the ID crosses the navigation boundary.
Open: app/src/main/java/com/mastercoding/phase3/MainActivity.kt
}
composable<RecipeDetail> { entry ->
val route = entry.toRoute<RecipeDetail>()
RecipeDetailScreen(
recipe = RecipeBook.find(route.recipeId),
isFavorite = route.recipeId in state.favoriteIds,
onToggleFavorite = { recipeViewModel.toggleFavorite(route.recipeId) },
onBack = { navController.popBackStack() }
)
}
}
}
}
@Composable
fun RecipeListScreen(favoriteIds: Set<Int>, onOpenRecipe: (Int) -> Unit) {
Column(
modifier = Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(20.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)Expected behavior
Pause and explain this step
Write the flow as card → ID → route → lookup → screen. Explain why forwarding a complete recipe would duplicate data already owned by RecipeBook.
Step 4: Handle both valid and unavailable recipes
RecipeDetailScreen receives a nullable Recipe. The valid branch renders ingredients and numbered instructions. The null branch displays Recipe not found with a usable Back button. The code never asserts that a lookup must succeed.
Open: app/src/main/java/com/mastercoding/phase3/MainActivity.kt · app/src/main/java/com/mastercoding/phase3/RecipeViewModel.kt
) {
TextButton(onClick = onBack) { Text("Back to recipes") }
if (recipe == null) {
Text("Recipe not found", style = MaterialTheme.typography.headlineMedium)
Text("This recipe is unavailable. Go back and choose another recipe.")
} else {
Text(recipe.title, style = MaterialTheme.typography.headlineMedium)
Text("Ready in ${recipe.minutes} minutes")
Button(onClick = onToggleFavorite) {
Text(if (isFavorite) "Remove favorite" else "Add favorite")
}
Text("Ingredients", style = MaterialTheme.typography.titleLarge)
recipe.ingredients.forEach { Text("• $it") }
Text("Steps", style = MaterialTheme.typography.titleLarge)
recipe.instructions.forEachIndexed { index, instruction ->
Text("${index + 1}. $instruction")
}
}
}Expected behavior
Pause and explain this step
Temporarily look up ID 999 in the destination, demonstrate the safe branch, then restore the original lookup. Ask which layer discovered the missing record and which layer explains it.
Step 5: Share favorite state between the screens
RecipeViewModel owns a private MutableStateFlow and exposes a read-only StateFlow. toggleFavorite checks the ID and creates a new set and state object. RecipeApp observes it with collectAsStateWithLifecycle and passes derived values and callbacks to both screens.
Open: app/src/main/java/com/mastercoding/phase3/RecipeViewModel.kt · app/src/main/java/com/mastercoding/phase3/MainActivity.kt
current.favoriteIds + id
}
current.copy(favoriteIds = updated)
}
}
}Expected behavior
Pause and explain this step
Trace button → ViewModel → copied set → emission → recomposition. Ask students to identify the single owner of favorite IDs and explain why separate destination ViewModels would split the state.
Step 6: Explain lifetime and verify the complete flow
The ViewModel is created above the destinations, so it is shared and survives activity recreation. It deliberately does not persist favorites across process death or force-stop. Scaffold applies innerPadding once to the navigation host, and scrollable screens remain usable with larger text.
Open: app/src/main/java/com/mastercoding/phase3/MainActivity.kt · README.md
navController = navController,
startDestination = RecipeList,
modifier = Modifier.fillMaxSize().padding(innerPadding).consumeWindowInsets(innerPadding)
) {
composable<RecipeList> {
RecipeListScreen(
favoriteIds = state.favoriteIds,
onOpenRecipe = { id -> navController.navigate(RecipeDetail(id)) }
)
}
composable<RecipeDetail> { entry ->
val route = entry.toRoute<RecipeDetail>()
RecipeDetailScreen(
recipe = RecipeBook.find(route.recipeId),
isFavorite = route.recipeId in state.favoriteIds,
onToggleFavorite = { recipeViewModel.toggleFavorite(route.recipeId) },
onBack = { navController.popBackStack() }
)
}Expected behavior
Pause and explain this step
Ask students to predict rotation versus force-stop before demonstrating each. Persistence is a later feature; make the current lifetime explicit in the assessment.
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/phase3/RecipeViewModel.kt | Three complete recipe records, ID lookup, immutable favorite state, and a small ViewModel. |
| app/src/main/java/com/mastercoding/phase3/MainActivity.kt | The launch Activity, serializable routes, navigation graph, list screen, and detail screen including the unavailable-recipe state. |
| README.md | Exact Android Studio/Gradle setup, a lecture sequence, manual verification, and the favorite-state lifetime. |
The launch Activity, serializable routes, navigation graph, list screen, and detail screen including the unavailable-recipe state.
16 complete text files. Copy the selected file with the code panel button, or download the full project above.
package com.mastercoding.phase3
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.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.MaterialTheme
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.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import androidx.navigation.toRoute
import kotlinx.serialization.Serializable
@Serializable
object RecipeList
@Serializable
data class RecipeDetail(val recipeId: Int)
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
MaterialTheme {
RecipeApp()
}
}
}
}
@Composable
fun RecipeApp(recipeViewModel: RecipeViewModel = viewModel()) {
val state by recipeViewModel.uiState.collectAsStateWithLifecycle()
val navController = rememberNavController()
Scaffold { innerPadding ->
NavHost(
navController = navController,
startDestination = RecipeList,
modifier = Modifier.fillMaxSize().padding(innerPadding).consumeWindowInsets(innerPadding)
) {
composable<RecipeList> {
RecipeListScreen(
favoriteIds = state.favoriteIds,
onOpenRecipe = { id -> navController.navigate(RecipeDetail(id)) }
)
}
composable<RecipeDetail> { entry ->
val route = entry.toRoute<RecipeDetail>()
RecipeDetailScreen(
recipe = RecipeBook.find(route.recipeId),
isFavorite = route.recipeId in state.favoriteIds,
onToggleFavorite = { recipeViewModel.toggleFavorite(route.recipeId) },
onBack = { navController.popBackStack() }
)
}
}
}
}
@Composable
fun RecipeListScreen(favoriteIds: Set<Int>, onOpenRecipe: (Int) -> Unit) {
Column(
modifier = Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(20.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Text("My Recipe Book", style = MaterialTheme.typography.headlineMedium)
Text("Choose one of three quick recipes. Favorites stay while the app is open.")
RecipeBook.recipes.forEach { recipe ->
Card(onClick = { onOpenRecipe(recipe.id) }, modifier = Modifier.fillMaxWidth()) {
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(recipe.title, style = MaterialTheme.typography.titleLarge)
Text("${recipe.minutes} minutes")
if (recipe.id in favoriteIds) Text("Favorite")
}
}
}
}
}
@Composable
fun RecipeDetailScreen(
recipe: Recipe?,
isFavorite: Boolean,
onToggleFavorite: () -> Unit,
onBack: () -> Unit
) {
Column(
modifier = Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(20.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
TextButton(onClick = onBack) { Text("Back to recipes") }
if (recipe == null) {
Text("Recipe not found", style = MaterialTheme.typography.headlineMedium)
Text("This recipe is unavailable. Go back and choose another recipe.")
} else {
Text(recipe.title, style = MaterialTheme.typography.headlineMedium)
Text("Ready in ${recipe.minutes} minutes")
Button(onClick = onToggleFavorite) {
Text(if (isFavorite) "Remove favorite" else "Add favorite")
}
Text("Ingredients", style = MaterialTheme.typography.titleLarge)
recipe.ingredients.forEach { Text("• $it") }
Text("Steps", style = MaterialTheme.typography.titleLarge)
recipe.instructions.forEachIndexed { index, instruction ->
Text("${index + 1}. $instruction")
}
}
}
}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 |
|---|---|
| Open all three cards, then return to the list after each. | Each card opens the matching title, duration, ingredients, and numbered instructions. |
| Favorite Tomato Toast, return, reopen, and remove its favorite. | Favorite appears on the card after adding and disappears after removal. |
| Use the system Back gesture from a detail screen. | The existing list returns; no duplicate destination is added. |
| Mark a favorite, rotate, then force-stop and relaunch. | Rotation retains the favorite; force-stop and a fresh session reset this in-memory state. |
| Temporarily replace the detail lookup with RecipeBook.find(999), then restore it. | Recipe not found appears, the Back button works, and there is no null-pointer crash. |
| Increase the device font size and scroll the detail screen. | Recipe text and the Back/favorite controls remain reachable. |
Common mistakes and repairs
- Creating the ViewModel independently inside each destination gives the screens different favorite state. Keep its owner at RecipeApp.
- Passing a whole recipe through navigation duplicates the source data. Pass its ID and perform a safe lookup.
- Mutating a set in place can hide state changes. Produce a new set and copy the state object.
- A ViewModel survives configuration changes, but it is not persistent storage. This project intentionally resets favorites after process death.
- Typed routes require both the Kotlin serialization compiler plugin and the Navigation Compose dependency. The included project configuration provides them.
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
Have a student trace the second card's click. The screen reports an event; RecipeApp decides where to go.
RecipeListScreen iterates over the three records and displays a card for each. A click invokes onOpenRecipe(recipe.id). This callback keeps screen rendering independent from the navigation controller. Expected result: The list contains three named cards and their preparation times. Tapping a card emits that record's ID.
Write the flow as card → ID → route → lookup → screen. Explain why forwarding a complete recipe would duplicate data already owned by RecipeBook.
RecipeList is a serializable object because it has no arguments. RecipeDetail is a serializable data class with recipeId. NavHost starts at RecipeList, navigate receives RecipeDetail(id), and toRoute reads the typed argument at the destination. Only the ID crosses the navigation boundary. Expected result: Tapping Tomato Toast opens its detail screen. The app Back button and system Back return to the list.
Temporarily look up ID 999 in the destination, demonstrate the safe branch, then restore the original lookup. Ask which layer discovered the missing record and which layer explains it.
RecipeDetailScreen receives a nullable Recipe. The valid branch renders ingredients and numbered instructions. The null branch displays Recipe not found with a usable Back button. The code never asserts that a lookup must succeed. Expected result: Valid IDs show their matching recipe. An unavailable ID shows guidance without crashing.
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
- Move English text into Android string resources and add a second language.
- Persist favorite IDs in DataStore after completing the storage lessons.
- Add a favorite-only filter while preserving the same single state owner.
After the intervening lessons, continue with Room Notes.
For deeper study, use the official Compose course and Android architecture guide.