Phase 3 · Navigation & ArchitectureModule 36~30 min read

Phase 3 Capstone · Offline Lesson Catalog

Browse three bundled lessons, open a lesson by ID, and mark it as a favorite. Two screens introduce typed navigation, Back, safe lookups, and ViewModel state without networking or persistent storage.

Phase 3: Offline Lesson Catalog

Browse three bundled lessons, open a lesson by ID, and mark it as a favorite. Two screens introduce typed navigation, Back, safe lookups, and ViewModel state without networking or persistent storage.

Guided workshop: 2–3 hours

Build the required features in order. Each step has an expected result and a complete reference file. All projects work offline after the initial dependency download.

Before you start

  • Navigation and typed routes
  • ViewModel and StateFlow
  • Lifecycle-aware collection

Application structure and responsibilities

  • Two screens: a list and details, with a Back action.

Concepts to explain: Typed Navigation Compose routes · Pass a lesson ID · Nullable lookups · ViewModel and StateFlow · Lifecycle-aware collection · Back navigation

What you will practice

  • Typed Navigation Compose routes
  • Pass a lesson ID
  • Nullable lookups
  • ViewModel and StateFlow
  • Lifecycle-aware collection
  • Back navigation

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 sample labels; localization is an optional follow-up exercise.

Implementation roadmap

  1. Model three lessons before drawing the screens: IDs 1, 2, and 3 resolve to Compose Text, Rows and Columns, and Button Events; another ID returns null.
  2. Render the list and report card taps: The list contains three named cards and their preparation times. Tapping a card emits that record's ID.
  3. Connect two typed destinations: Tapping Compose Text opens its detail screen. The app Back button and system Back return to the list.
  4. Handle both valid and unavailable lessons: Valid IDs show their matching lesson. An unavailable ID shows guidance without crashing.
  5. Share favorite state between the screens: Mark a lesson as favorite, navigate back, and see Favorite on its card. Reopening the lesson offers Remove favorite.
  6. Explain lifetime and verify the complete flow: Rotation preserves the selected detail and favorite state. Force-stop and a fresh launch reset favorites. System bars do not cover the content.

Build the app step by step

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. Run and check each small change before moving on.

Step 1: Model three lessons before drawing the screens

Lesson is one record with an integer ID, a title, a duration, topics, and practiceSteps. LessonBook holds three ready-to-use records. Its find function returns Lesson? so callers must handle a missing ID.

Open: app/src/main/java/com/mastercoding/phase3/LessonViewModel.kt

LessonViewModel.kt
import kotlinx.coroutines.flow.update

data class Lesson(
    val id: Int,
    val title: String,
    val minutes: Int,
    val topics: List<String>,
    val practiceSteps: List<String>
)

object LessonBook {
    val lessons = listOf(
        Lesson(
            id = 1,
            title = "Compose Text",
            minutes = 5,
            topics = listOf("Text composable", "String values", "Preview"),
            practiceSteps = listOf("Create a Text composable.", "Pass a message string.", "Preview the message.")
        ),
Focused excerpt, lines 7–25. Select this file in Complete source code for every line.

Expected behavior

IDs 1, 2, and 3 resolve to Compose Text, Rows and Columns, and Button Events; another ID returns null.

Step 2: Render the list and report card taps

LessonListScreen iterates over the three records and displays a card for each. A click invokes onOpenLesson(lesson.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 LessonListScreen(favoriteIds: Set<Int>, onOpenLesson: (Int) -> Unit) {
    Column(
        modifier = Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(20.dp),
        verticalArrangement = Arrangement.spacedBy(16.dp)
    ) {
        Text("Offline Lesson Catalog", style = MaterialTheme.typography.headlineMedium)
        Text("Choose one of three quick lessons. Favorites stay while the app is open.")
        LessonBook.lessons.forEach { lesson ->
            Card(onClick = { onOpenLesson(lesson.id) }, modifier = Modifier.fillMaxWidth()) {
                Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
                    Text(lesson.title, style = MaterialTheme.typography.titleLarge)
                    Text("${lesson.minutes} minutes")
                    if (lesson.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.

Step 3: Connect two typed destinations

LessonList is a serializable object because it has no arguments. LessonDetail is a serializable data class with lessonId. NavHost starts at LessonList, navigate receives LessonDetail(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<LessonDetail> { entry ->
                val route = entry.toRoute<LessonDetail>()
                LessonDetailScreen(
                    lesson = LessonBook.find(route.lessonId),
                    isFavorite = route.lessonId in state.favoriteIds,
                    onToggleFavorite = { lessonViewModel.toggleFavorite(route.lessonId) },
                    onBack = { navController.popBackStack() }
                )
            }
        }
    }
}

@Composable
fun LessonListScreen(favoriteIds: Set<Int>, onOpenLesson: (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 Compose Text opens its detail screen. The app Back button and system Back return to the list.

Step 4: Handle both valid and unavailable lessons

LessonDetailScreen receives a nullable Lesson. The valid branch renders topics and numbered practiceSteps. The null branch displays Lesson 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/LessonViewModel.kt

MainActivity.kt
    ) {
        TextButton(onClick = onBack) { Text("Back to lessons") }
        if (lesson == null) {
            Text("Lesson not found", style = MaterialTheme.typography.headlineMedium)
            Text("This lesson is unavailable. Go back and choose another lesson.")
        } else {
            Text(lesson.title, style = MaterialTheme.typography.headlineMedium)
            Text("Study time: ${lesson.minutes} minutes")
            Button(onClick = onToggleFavorite) {
                Text(if (isFavorite) "Remove favorite" else "Add favorite")
            }
            Text("Topics", style = MaterialTheme.typography.titleLarge)
            lesson.topics.forEach { Text("• $it") }
            Text("Steps", style = MaterialTheme.typography.titleLarge)
            lesson.practiceSteps.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 lesson. An unavailable ID shows guidance without crashing.

Step 5: Share favorite state between the screens

LessonViewModel owns a private MutableStateFlow and exposes a read-only StateFlow. toggleFavorite checks the ID and creates a new set and state object. LessonApp observes it with collectAsStateWithLifecycle and passes derived values and callbacks to both screens.

Open: app/src/main/java/com/mastercoding/phase3/LessonViewModel.kt · app/src/main/java/com/mastercoding/phase3/MainActivity.kt

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

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 = LessonList,
            modifier = Modifier.fillMaxSize().padding(innerPadding).consumeWindowInsets(innerPadding)
        ) {
            composable<LessonList> {
                LessonListScreen(
                    favoriteIds = state.favoriteIds,
                    onOpenLesson = { id -> navController.navigate(LessonDetail(id)) }
                )
            }
            composable<LessonDetail> { entry ->
                val route = entry.toRoute<LessonDetail>()
                LessonDetailScreen(
                    lesson = LessonBook.find(route.lessonId),
                    isFavorite = route.lessonId in state.favoriteIds,
                    onToggleFavorite = { lessonViewModel.toggleFavorite(route.lessonId) },
                    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.

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.

FilePurpose
app/src/main/java/com/mastercoding/phase3/LessonViewModel.ktThree complete lesson 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-lesson state.
README.mdExact Android Studio/Gradle setup, a build sequence, manual verification, and the favorite-state lifetime.

The launch Activity, serializable routes, navigation graph, list screen, and detail screen including the unavailable-lesson 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 LessonList

@Serializable
data class LessonDetail(val lessonId: Int)

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        enableEdgeToEdge()
        setContent {
            MaterialTheme {
                LessonApp()
            }
        }
    }
}

@Composable
fun LessonApp(lessonViewModel: LessonViewModel = viewModel()) {
    val state by lessonViewModel.uiState.collectAsStateWithLifecycle()
    val navController = rememberNavController()
    Scaffold { innerPadding ->
        NavHost(
            navController = navController,
            startDestination = LessonList,
            modifier = Modifier.fillMaxSize().padding(innerPadding).consumeWindowInsets(innerPadding)
        ) {
            composable<LessonList> {
                LessonListScreen(
                    favoriteIds = state.favoriteIds,
                    onOpenLesson = { id -> navController.navigate(LessonDetail(id)) }
                )
            }
            composable<LessonDetail> { entry ->
                val route = entry.toRoute<LessonDetail>()
                LessonDetailScreen(
                    lesson = LessonBook.find(route.lessonId),
                    isFavorite = route.lessonId in state.favoriteIds,
                    onToggleFavorite = { lessonViewModel.toggleFavorite(route.lessonId) },
                    onBack = { navController.popBackStack() }
                )
            }
        }
    }
}

@Composable
fun LessonListScreen(favoriteIds: Set<Int>, onOpenLesson: (Int) -> Unit) {
    Column(
        modifier = Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(20.dp),
        verticalArrangement = Arrangement.spacedBy(16.dp)
    ) {
        Text("Offline Lesson Catalog", style = MaterialTheme.typography.headlineMedium)
        Text("Choose one of three quick lessons. Favorites stay while the app is open.")
        LessonBook.lessons.forEach { lesson ->
            Card(onClick = { onOpenLesson(lesson.id) }, modifier = Modifier.fillMaxWidth()) {
                Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
                    Text(lesson.title, style = MaterialTheme.typography.titleLarge)
                    Text("${lesson.minutes} minutes")
                    if (lesson.id in favoriteIds) Text("Favorite")
                }
            }
        }
    }
}

@Composable
fun LessonDetailScreen(
    lesson: Lesson?,
    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 lessons") }
        if (lesson == null) {
            Text("Lesson not found", style = MaterialTheme.typography.headlineMedium)
            Text("This lesson is unavailable. Go back and choose another lesson.")
        } else {
            Text(lesson.title, style = MaterialTheme.typography.headlineMedium)
            Text("Study time: ${lesson.minutes} minutes")
            Button(onClick = onToggleFavorite) {
                Text(if (isFavorite) "Remove favorite" else "Add favorite")
            }
            Text("Topics", style = MaterialTheme.typography.titleLarge)
            lesson.topics.forEach { Text("• $it") }
            Text("Steps", style = MaterialTheme.typography.titleLarge)
            lesson.practiceSteps.forEachIndexed { index, instruction ->
                Text("${index + 1}. $instruction")
            }
        }
    }
}

Definition of done

Project acceptance checklist0 / 7 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, topics, and numbered practiceSteps.
Favorite Compose Text, 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 LessonBook.find(999), then restore it.Lesson not found appears, the Back button works, and there is no null-pointer crash.
Increase the device font size and scroll the detail screen.Lesson 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 LessonApp.
  • Passing a whole lesson 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.

Assessment rubric

CriterionPointsEvidence
Core behavior40All acceptance scenarios produce their expected results.
Clear state ownership30Trace one event from its callback to the displayed result and identify the owner of each value.
Boundary cases20Verify the listed invalid, empty, and restoration cases.
Readable implementation10Use descriptive names and small functions; describe any known limitation.

Total: 100 points. Demonstrate the working app, trace one event through the source, and verify a boundary case.

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 Reading Log.

For deeper study, use the official Compose course and Android architecture guide.