Phase 3 · Navigation & ArchitectureModule 23~30 min read

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

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

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

  • 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.

Phase 3 Interactive concept studio

Navigate by ID, then update a favorite

Typed route → catalog lookup → shared ViewModel → both screens

Follow this interactionOpen Banana Yogurt Bowl, recipe #2, mark it as a favorite, and return to the recipe list.
01Active now
Navigation

Carries recipeId between destinations

02Responsibility
Recipe catalog

Looks up a recipe by ID

03Responsibility
Recipe ViewModel

Owns favorite IDs in StateFlow

04Responsibility
Compose screens

Render recipe data and favorite state

Compose screens
RecipeDetail(recipeId = 2)
Navigation

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.

State snapshot
selected recipe
Banana Yogurt Bowl
route argument
recipeId = 2
favorite IDs
{}

Does this route need the recipe title, ingredient list, and instructions?

Connect this frame to the Kotlin source

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

MainActivity.kt
RecipeListScreen(
                    favoriteIds = state.favoriteIds,
                    onOpenRecipe = { id -> navController.navigate(RecipeDetail(id)) }
                )
A focused source excerpt. The complete file and runnable project are in the source section below.
Open the complete source section →
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.

  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

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.

Lesson sequence1 / 6
Step 1 of 6

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.

Up nextRender the list and report card taps
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: 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

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

Expected behavior

IDs 1, 2, and 3 resolve to Tomato Toast, Banana Yogurt Bowl, and Cucumber Salad; another ID returns null.
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

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

Expected behavior

The list contains three named cards and their preparation times. Tapping a card emits that record's ID.
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

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

Expected behavior

Tapping Tomato Toast opens its detail screen. The app Back button and system Back return to the list.
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

MainActivity.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")
            }
        }
    }
Focused excerpt, lines 110–128. Select this file in Complete source code for every line.

Expected behavior

Valid IDs show their matching recipe. An unavailable ID shows guidance without crashing.
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

RecipeViewModel.kt
                current.favoriteIds + id
            }
            current.copy(favoriteIds = updated)
        }
    }
}
Focused excerpt, lines 57–63. Select this file in Complete source code for every line.

Expected behavior

Mark a recipe as favorite, navigate back, and see Favorite on its card. Reopening the recipe offers Remove favorite.
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

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

Expected behavior

Rotation preserves the selected detail and favorite state. Force-stop and a fresh launch reset favorites. System bars do not cover the content.
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.

FileWhat to explain
app/src/main/java/com/mastercoding/phase3/RecipeViewModel.ktThree complete recipe records, ID lookup, immutable favorite state, and a small ViewModel.
app/src/main/java/com/mastercoding/phase3/MainActivity.ktThe launch Activity, serializable routes, navigation graph, list screen, and detail screen including the unavailable-recipe state.
README.mdExact 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.

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

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

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

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

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

  • 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.