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
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.
- 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
- 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.
- 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.
- Connect two typed destinations: Tapping Compose Text opens its detail screen. The app Back button and system Back return to the list.
- Handle both valid and unavailable lessons: Valid IDs show their matching lesson. An unavailable ID shows guidance without crashing.
- 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.
- 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
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.")
),Expected behavior
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
@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")
}
}
}
}Expected behavior
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
}
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)Expected behavior
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
) {
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")
}
}
}Expected behavior
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
current.favoriteIds + id
}
current.copy(favoriteIds = updated)
}
}
}Expected behavior
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 = 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() }
)
}Expected behavior
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 | Purpose |
|---|---|
| app/src/main/java/com/mastercoding/phase3/LessonViewModel.kt | Three complete lesson 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-lesson state. |
| README.md | Exact 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.
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
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, 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
| Criterion | Points | Evidence |
|---|---|---|
| Core behavior | 40 | All acceptance scenarios produce their expected results. |
| Clear state ownership | 30 | Trace one event from its callback to the displayed result and identify the owner of each value. |
| Boundary cases | 20 | Verify the listed invalid, empty, and restoration cases. |
| Readable implementation | 10 | Use 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.