Techbase Academy
Android · Lesson 26

Navigation in Jetpack Compose: The Cupcake App

12 phasesThree-Screen Quiz AppAndroid Studio
Phase 1 of 12
Lesson Introduction

So far every app you built has one screen. Real apps have many: a login screen, a list, a detail page, a settings page, a checkout. Moving between them, and remembering where the user came from so that Back works, is called navigation.

In this lesson you build Cupcake, a bakery ordering app with four screens: start, choose flavour, choose pickup date, and order summary. You will learn the Navigation component for Compose (NavHost, NavController, routes), pass data between screens through a shared ViewModel, add a top app bar with an Up button, share the order using an Android intent, and write a test that checks navigation.

This lesson covers the four items of Pathway 2, Unit 4 of Google's Android Basics with Compose course: Introduction to Navigation and Cupcake App, Navigate between screens with Compose, Test the Cupcake app and Practice: Add navigation.

By the end you will be able to:

  • Explain the back stack and why navigation is not "just show another composable".
  • Set up a NavHost with a NavController and define routes.
  • Use type-safe routes (@Serializable objects) and know the older string routes you will meet in existing code.
  • Navigate forward, pop back, and clear the back stack to the start.
  • Share state between screens with a ViewModel.
  • Show a TopAppBar that adapts its back button to the current screen.
  • Launch a share sheet with Intent.ACTION_SEND.
  • Test that tapping a button lands on the right screen.

Big idea: navigation is a stack of destinations. Going forward pushes a screen on the stack, and Back pops it off. The NavController owns the stack, and the NavHost draws whichever destination is on top.

✏️ Your Task
Practise what you just learned about Lesson Introduction. Open Android Studio, type the examples by hand, change a value, and observe what happens.
Phase 2 of 12
Prerequisite Concepts
  • ViewModel, UI state and StateFlow (lessons 24 and 25). Cupcake keeps its order in one ViewModel.
  • Lambdas as callbacks (lesson 12). Screens receive onNextButtonClicked: () -> Unit instead of knowing about navigation.
  • State hoisting (lesson 24). The navigation host is the top-level state owner.
  • Gradle dependencies (lesson 24 showed the pattern; lesson 28 explains it). You will add navigation-compose and the serialization plugin.
✏️ Your Task
Practise what you just learned about Prerequisite Concepts. Open Android Studio, type the examples by hand, change a value, and observe what happens.
Phase 3 of 12
Conceptual Understanding: The Back Stack

Think of a pile of plates in a canteen. A new plate goes on top (you navigated forward). Taking the top plate away reveals the previous one (you pressed Back). You can only touch the top.

text
 User taps Start       Taps Flavour        Taps Next        Presses Back
 +-----------+        +-----------+       +-----------+     +-----------+
 |           |        |           |       |  Pickup   |     |           |
 |           |        |  Flavour  |       |  Flavour  |     |  Flavour  |
 |   Start   |        |   Start   |       |   Start   |     |   Start   |
 +-----------+        +-----------+       +-----------+     +-----------+
   back stack           back stack          back stack        back stack

Three terms:

Term Meaning
Destination One screen the user can be on, such as the flavour screen.
Route The identity of a destination. It is how you tell the controller where to go.
Back stack The list of destinations the user has visited, most recent on top.

Three components do the work:

Component Role
NavController Holds the back stack and exposes navigate() and popBackStack().
NavHost A composable that shows the destination currently on top of the stack.
Navigation graph The set of all destinations, defined inside the NavHost lambda.

You create the controller with rememberNavController(), which keeps it across recompositions and rotation.

Why not just use if statements?

You could write if (screen == "flavour") FlavourScreen() else ... with a var screen. It works, until: the system Back button does nothing sensible, rotation loses the position, deep links cannot reach a screen, and screens cannot be animated or tested in isolation. The Navigation library handles all of those.

✏️ Your Task
Practise what you just learned about Conceptual Understanding: The Back Stack. Open Android Studio, type the examples by hand, change a value, and observe what happens.
Phase 4 of 12
Step-by-Step Build 1: Set Up Navigation

Create a new Empty Activity project called Cupcake (package com.example.cupcake).

Add the dependencies

You need the Navigation Compose library and Kotlin serialization (for type-safe routes). In gradle/libs.versions.toml add entries following this shape, looking up the latest stable versions from the official release pages (https://developer.android.com/jetpack/androidx/releases/navigation and https://github.com/Kotlin/kotlinx.serialization/releases):

libs.versions.toml
[versions]
navigationCompose = "<latest stable>"
kotlinxSerializationJson = "<latest stable>"
[libraries]
androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigationCompose" }
kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinxSerializationJson" }
[plugins]
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }

Your catalog already defines a kotlin version used by the Compose compiler plugin, so version.ref = "kotlin" reuses it. If your catalog names it differently, use that name. Then apply the plugin and libraries.

In the root build.gradle.kts:

build.gradle.kts
plugins {
 alias(libs.plugins.kotlin.serialization) apply false
}

In app/build.gradle.kts:

build.gradle.kts
plugins {
 alias(libs.plugins.kotlin.serialization)
}
dependencies {
 implementation(libs.androidx.navigation.compose)
 implementation(libs.kotlinx.serialization.json)
}

Click Sync Now. The exact layout of the root file differs between Android Studio versions, so match the existing lines in your project.

Define the routes

A route names a destination. In current Navigation Compose you define routes as serializable Kotlin types, which the compiler checks for you:

Routes.kt
package com.example.cupcake
import kotlinx.serialization.Serializable
@Serializable object StartRoute
@Serializable object FlavorRoute
@Serializable object PickupRoute
@Serializable object SummaryRoute

@Serializable object is a singleton route with no arguments. If a destination needs data, you use a data class instead (shown later).

Older tutorials, and the official Cupcake codelab in some versions, use string routes such as "start". They still work and you will meet them in older code, so here is the contrast:

Style Define Navigate
Type-safe (preferred now) @Serializable object FlavorRoute navController.navigate(FlavorRoute)
String route const val FLAVOR = "flavor" navController.navigate("flavor")

A typo in a string is a runtime crash. A typo in a type name does not compile. If your Android Studio is older and the type-safe overload is not found, update the Navigation library to a recent stable release (type-safe navigation arrived in Navigation 2.8).

✏️ Your Task
Practise what you just learned about Step-by-Step Build 1: Set Up Navigation. Open Android Studio, type the examples by hand, change a value, and observe what happens.
Phase 5 of 12
Step-by-Step Build 2: The Order State

Create a data class and a ViewModel that every screen shares.

OrderUiState.kt
package com.example.cupcake
data class OrderUiState(
 val quantity: Int = 0,
 val flavor: String = "",
 val date: String = "",
 val price: Double = 0.0,
 val pickupOptions: List<String> = emptyList()
)
OrderViewModel.kt
package com.example.cupcake
import androidx.lifecycle.ViewModel
import java.text.SimpleDateFormat
import java.util.Calendar
import java.util.Locale
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
private const val PRICE_PER_CUPCAKE = 500.0 // naira
private const val PRICE_FOR_SAME_DAY_PICKUP = 300.0
class OrderViewModel : ViewModel() {
 private val _uiState = MutableStateFlow(OrderUiState(pickupOptions = pickupOptions()))
 val uiState: StateFlow<OrderUiState> = _uiState.asStateFlow()
 fun setQuantity(numberCupcakes: Int) {
 _uiState.update { it.copy(
 quantity = numberCupcakes,
 price = calculatePrice(quantity = numberCupcakes)
 ) }
 }
 fun setFlavor(desiredFlavor: String) {
 _uiState.update { it.copy(flavor = desiredFlavor) }
 }
 fun setDate(pickupDate: String) {
 _uiState.update { it.copy(
 date = pickupDate,
 price = calculatePrice(pickupDate = pickupDate)
 ) }
 }
 fun resetOrder() {
 _uiState.value = OrderUiState(pickupOptions = pickupOptions())
 }
 private fun calculatePrice(
 quantity: Int = _uiState.value.quantity,
 pickupDate: String = _uiState.value.date
 ): Double {
 var total = quantity * PRICE_PER_CUPCAKE
 if (pickupOptions()[0] == pickupDate) total += PRICE_FOR_SAME_DAY_PICKUP
 return total
 }
 private fun pickupOptions(): List<String> {
 val formatter = SimpleDateFormat("E MMM d", Locale.getDefault())
 val calendar = Calendar.getInstance()
 return List(4) {
 formatter.format(calendar.time).also { calendar.add(Calendar.DATE, 1) }
 }
 }
}

Points worth noting:

  • Prices are in naira and kept as Double for simplicity. Money in a real shop app should use whole kobo in a Long, or BigDecimal, to avoid floating-point surprises.
  • pickupOptions() builds four dates starting today: List(4) { ... } runs the lambda four times.
  • calculatePrice adds a same-day surcharge when the chosen date is the first option (today).
  • The function uses default parameters that read the current state, so each setter recalculates cleanly.
  • Nothing here knows about navigation. It only holds order data.
✏️ Your Task
Practise what you just learned about Step-by-Step Build 2: The Order State. Open Android Studio, type the examples by hand, change a value, and observe what happens.
Phase 6 of 12
Step-by-Step Build 3: The Four Screens

Each screen is stateless and gets callbacks. They know nothing about the NavController, which keeps them reusable and testable. To keep this lesson focused we use simple, plain screens.

Screens.kt
package com.example.cupcake
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.compose.foundation.selection.selectable
import androidx.compose.foundation.layout.Row
@Composable
fun StartScreen(
 quantityOptions: List<Pair<String, Int>>,
 onNextButtonClicked: (Int) -> Unit,
 modifier: Modifier = Modifier
) {
 Column(
 modifier = modifier.fillMaxSize().padding(24.dp),
 verticalArrangement = Arrangement.Center,
 horizontalAlignment = Alignment.CenterHorizontally
 ) {
 Text("Order Cupcakes", style = MaterialTheme.typography.headlineMedium)
 Text("How many would you like?", modifier = Modifier.padding(vertical = 16.dp))
 quantityOptions.forEach { (label, count) ->
 Button(
 onClick = { onNextButtonClicked(count) },
 modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp)
 ) { Text(label) }
 }
 }
}
@Composable
fun ChoiceScreen(
 title: String,
 options: List<String>,
 selected: String,
 onSelectionChanged: (String) -> Unit,
 subtotal: Double,
 onNextButtonClicked: () -> Unit,
 onCancelButtonClicked: () -> Unit,
 modifier: Modifier = Modifier
) {
 Column(modifier = modifier.fillMaxSize().padding(16.dp)) {
 Text(title, style = MaterialTheme.typography.titleLarge)
 options.forEach { item ->
 Row(
 modifier = Modifier
 .fillMaxWidth()
 .selectable(
 selected = selected == item,
 onClick = { onSelectionChanged(item) }
 ),
 verticalAlignment = Alignment.CenterVertically
 ) {
 RadioButton(selected = selected == item, onClick = null)
 Text(item, modifier = Modifier.padding(start = 8.dp))
 }
 }
 Text("Subtotal: ₦%.0f".format(subtotal), modifier = Modifier.padding(vertical = 16.dp))
 Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
 OutlinedButton(onClick = onCancelButtonClicked) { Text("Cancel") }
 Button(onClick = onNextButtonClicked, enabled = selected.isNotEmpty()) { Text("Next") }
 }
 }
}
@Composable
fun SummaryScreen(
 orderUiState: OrderUiState,
 onSendButtonClicked: (subject: String, summary: String) -> Unit,
 onCancelButtonClicked: () -> Unit,
 modifier: Modifier = Modifier
) {
 val summary = "Quantity: ${orderUiState.quantity}\n" +
 "Flavor: ${orderUiState.flavor}\n" +
 "Pickup: ${orderUiState.date}\n" +
 "Total: ₦%.0f".format(orderUiState.price)
 Column(modifier = modifier.fillMaxSize().padding(16.dp)) {
 Text("Order summary", style = MaterialTheme.typography.titleLarge)
 Text(summary, modifier = Modifier.padding(vertical = 16.dp))
 Button(
 onClick = { onSendButtonClicked("New cupcake order", summary) },
 modifier = Modifier.fillMaxWidth()
 ) { Text("Send order") }
 OutlinedButton(
 onClick = onCancelButtonClicked,
 modifier = Modifier.fillMaxWidth().padding(top = 8.dp)
 ) { Text("Cancel") }
 }
}

Two things to notice:

  • RadioButton(selected = ..., onClick = null) with the click handled on the whole Row through selectable. This makes the entire row tappable and is better for accessibility (lesson 46) because the row is one element for a screen reader.
  • "Total: ₦%.0f".format(...) binds to the whole string literal here. It works because format is called on the last string in the concatenation. When you chain several strings with +, remember that .format() applies only to the string right before it. For clarity in your own code, build the summary with a template or buildString.
✏️ Your Task
Practise what you just learned about Step-by-Step Build 3: The Four Screens. Open Android Studio, type the examples by hand, change a value, and observe what happens.
Phase 7 of 12
Step-by-Step Build 4: The NavHost

The top-level composable owns the controller and wires everything. First, the top bar. A TopAppBar shows the title and an Up arrow when there is somewhere to go back to:

CupcakeApp.kt
package com.example.cupcake
import android.content.Context
import android.content.Intent
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.CenterAlignedTopAppBar
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavHostController
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun CupcakeAppBar(
 title: String,
 canNavigateBack: Boolean,
 navigateUp: () -> Unit,
 modifier: Modifier = Modifier
) {
 CenterAlignedTopAppBar(
 title = { Text(title) },
 modifier = modifier,
 navigationIcon = {
 if (canNavigateBack) {
 IconButton(onClick = navigateUp) {
 Icon(
 imageVector = Icons.AutoMirrored.Filled.ArrowBack,
 contentDescription = "Back"
 )
 }
 }
 }
 )
}

(Icons.AutoMirrored.Filled.ArrowBack comes from the material-icons-core artifact that Compose BOM-based projects include; it flips automatically in right-to-left languages such as Arabic.)

Now the host itself, in the same file:

kotlin
@Composable
fun CupcakeApp(
 viewModel: OrderViewModel = viewModel(),
 navController: NavHostController = rememberNavController()
) {
 val uiState by viewModel.uiState.collectAsStateWithLifecycle()
 // Is there a screen behind this one?
 val canNavigateBack = navController.previousBackStackEntry != null
 Scaffold(
 topBar = {
 CupcakeAppBar(
 title = "Cupcake",
 canNavigateBack = canNavigateBack,
 navigateUp = { navController.navigateUp() }
 )
 }
 ) { innerPadding ->
 NavHost(
 navController = navController,
 startDestination = StartRoute,
 modifier = Modifier.padding(innerPadding)
 ) {
 composable<StartRoute> {
 StartScreen(
 quantityOptions = listOf("One cupcake" to 1, "Six cupcakes" to 6, "Twelve cupcakes" to 12),
 onNextButtonClicked = {
 viewModel.setQuantity(it)
 navController.navigate(FlavorRoute)
 }
 )
 }
 composable<FlavorRoute> {
 ChoiceScreen(
 title = "Choose flavor",
 options = listOf("Vanilla", "Chocolate", "Red velvet", "Coffee"),
 selected = uiState.flavor,
 onSelectionChanged = viewModel::setFlavor,
 subtotal = uiState.price,
 onNextButtonClicked = { navController.navigate(PickupRoute) },
 onCancelButtonClicked = { cancelOrderAndNavigateToStart(viewModel, navController) }
 )
 }
 composable<PickupRoute> {
 ChoiceScreen(
 title = "Choose pickup date",
 options = uiState.pickupOptions,
 selected = uiState.date,
 onSelectionChanged = viewModel::setDate,
 subtotal = uiState.price,
 onNextButtonClicked = { navController.navigate(SummaryRoute) },
 onCancelButtonClicked = { cancelOrderAndNavigateToStart(viewModel, navController) }
 )
 }
 composable<SummaryRoute> {
 val context = LocalContext.current
 SummaryScreen(
 orderUiState = uiState,
 onSendButtonClicked = { subject, summary ->
 shareOrder(context, subject, summary)
 },
 onCancelButtonClicked = { cancelOrderAndNavigateToStart(viewModel, navController) }
 )
 }
 }
 }
}
private fun cancelOrderAndNavigateToStart(
 viewModel: OrderViewModel,
 navController: NavHostController
) {
 viewModel.resetOrder()
 navController.popBackStack(StartRoute, inclusive = false)
}
private fun shareOrder(context: Context, subject: String, summary: String) {
 val intent = Intent(Intent.ACTION_SEND).apply {
 type = "text/plain"
 putExtra(Intent.EXTRA_SUBJECT, subject)
 putExtra(Intent.EXTRA_TEXT, summary)
 }
 context.startActivity(Intent.createChooser(intent, "New cupcake order"))
}

Call CupcakeApp() from setContent, inside your theme.

Reading the code

  • rememberNavController() creates the controller. Giving it as a default parameter lets tests pass their own.
  • previousBackStackEntry != null is true when there is a screen behind the current one, so the Up arrow appears on every screen except the start. (A common alternative is to observe currentBackStackEntryAsState() so the app bar recomposes with the route; for this simple case the property read works because navigation changes cause the host to recompose, but when you start showing route-specific titles use the observed form.)
  • NavHost(startDestination = StartRoute) picks the first screen.
  • composable<StartRoute> { ... } registers a destination. The lambda is the screen.
  • navController.navigate(FlavorRoute) pushes a new destination.
  • navController.popBackStack(StartRoute, inclusive = false) pops everything above the start screen but keeps the start screen. inclusive = true would remove the start too.
  • The ViewModel is created once at the top and shared by every destination, so the order survives moving between screens and rotation.
  • Screens receive only data and lambdas. They have no NavController parameter.

Run it. Pick six cupcakes, choose a flavour, a date, see the summary, tap Send to open the share sheet, then Cancel to return to the start with a reset order.

✏️ Your Task
Practise what you just learned about Step-by-Step Build 4: The NavHost. Open Android Studio, type the examples by hand, change a value, and observe what happens.
Phase 8 of 12
Passing Data Through the Route

Sharing a ViewModel is the right tool for data created by the user across the flow. For data that identifies the destination, such as "show product 42", put it in the route. With type-safe routes, use a data class:

kotlin
@Serializable data class ProductRoute(val productId: Int)
NavHost(navController, startDestination = StartRoute) {
 composable<ProductRoute> { backStackEntry ->
 val route: ProductRoute = backStackEntry.toRoute()
 ProductScreen(productId = route.productId)
 }
}
// somewhere else
navController.navigate(ProductRoute(productId = 42))

toRoute() is imported from androidx.navigation.toRoute. Keep arguments small and simple (IDs, short strings). Never pass whole objects or large lists through the route. Pass the ID and let the destination's ViewModel load the data (lesson 30).

With the older string-route style the same example reads composable("product/{id}", arguments = listOf(navArgument("id") { type = NavType.IntType })) and navigate("product/42"). It is more error-prone, which is why new code uses the type-safe form.

✏️ Your Task
Practise what you just learned about Passing Data Through the Route. Open Android Studio, type the examples by hand, change a value, and observe what happens.
Phase 9 of 12
Testing the Navigation

You test navigation in app/src/androidTest using a TestNavHostController, a version of the controller meant for tests.

Add the dependency

build.gradle.kts
dependencies {
 androidTestImplementation(libs.androidx.navigation.testing)
}

with a catalog entry androidx-navigation-testing = { group = "androidx.navigation", name = "navigation-testing", version.ref = "navigationCompose" } (it shares the Navigation version).

The test

CupcakeNavigationTest.kt
package com.example.cupcake
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.platform.LocalContext
import androidx.navigation.compose.ComposeNavigator
import androidx.navigation.testing.TestNavHostController
import androidx.navigation.toRoute
import org.junit.Assert.assertEquals
import org.junit.Before
import org.junit.Rule
import org.junit.Test
class CupcakeNavigationTest {
 @get:Rule
 val composeTestRule = createComposeRule()
 private lateinit var navController: TestNavHostController
 @Before
 fun setupCupcakeNavHost() {
 composeTestRule.setContent {
 navController = TestNavHostController(LocalContext.current).apply {
 navigatorProvider.addNavigator(ComposeNavigator())
 }
 CupcakeApp(navController = navController)
 }
 }
 @Test
 fun navHost_verifyStartDestination() {
 composeTestRule.onNodeWithText("Order Cupcakes").assertExists()
 }
 @Test
 fun navHost_clickOneCupcake_navigatesToFlavorScreen() {
 composeTestRule.onNodeWithText("One cupcake").performClick()
 composeTestRule.onNodeWithText("Choose flavor").assertExists()
 }
 @Test
 fun navHost_clickCancelOnFlavor_returnsToStart() {
 composeTestRule.onNodeWithText("One cupcake").performClick()
 composeTestRule.onNodeWithText("Cancel").performClick()
 composeTestRule.onNodeWithText("Order Cupcakes").assertExists()
 }
}

assertExists() is a member function of the node, so it needs no import. The toRoute and assertEquals imports are only needed for tests that inspect navController.currentBackStackEntry; you can delete them from the file above if you do not use them. The tests assert on what the user sees (screen text), which is sturdier than asserting on internal route names.

Run it with the green arrow beside the class. It needs an emulator or device, because it is an instrumented test.

✏️ Your Task
Practise what you just learned about Testing the Navigation. Open Android Studio, type the examples by hand, change a value, and observe what happens.
Phase 10 of 12
Guided Practice Exercises
🎯 Your Challenge

Exercise 1: Add a Settings destination (the practice)

Add a fifth screen, SettingsRoute, reachable from a settings icon on the top bar of the Start screen only. It shows a Switch labelled Express pickup by default. The back arrow must work. Do not add it to the cupcake order flow's back stack beyond one entry.

kotlin
// Routes.kt
@Serializable object SettingsRoute
// In CupcakeAppBar, add an actions parameter
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun CupcakeAppBar(
 title: String,
 canNavigateBack: Boolean,
 navigateUp: () -> Unit,
 onSettingsClick: (() -> Unit)? = null,
 modifier: Modifier = Modifier
) {
 CenterAlignedTopAppBar(
 title = { Text(title) },
 modifier = modifier,
 navigationIcon = { /* as before */ },
 actions = {
 if (onSettingsClick != null) {
 IconButton(onClick = onSettingsClick) {
 Icon(Icons.Filled.Settings, contentDescription = "Settings")
 }
 }
 }
 )
}
// In CupcakeApp: only show the action on the start destination
val backStackEntry by navController.currentBackStackEntryAsState()
val onStart = backStackEntry?.destination?.hasRoute<StartRoute>() == true
CupcakeAppBar(
 title = "Cupcake",
 canNavigateBack = canNavigateBack,
 navigateUp = { navController.navigateUp() },
 onSettingsClick = if (onStart) { { navController.navigate(SettingsRoute) } } else null
)
// In the NavHost
composable<SettingsRoute> {
 var express by rememberSaveable { mutableStateOf(false) }
 Row(Modifier.padding(16.dp), verticalAlignment = Alignment.CenterVertically) {
 Text("Express pickup by default", modifier = Modifier.weight(1f))
 Switch(checked = express, onCheckedChange = { express = it })
 }
}

Imports needed: androidx.compose.material.icons.filled.Settings (from material-icons-extended? Settings is in the core set, so material-icons-core is enough), androidx.navigation.compose.currentBackStackEntryAsState, androidx.navigation.hasRoute, androidx.compose.material3.Switch, androidx.compose.foundation.layout.Row, androidx.compose.runtime.mutableStateOf, androidx.compose.runtime.saveable.rememberSaveable.

Why: currentBackStackEntryAsState() converts the back stack's current entry into Compose state, so the bar updates when the destination changes. hasRoute<StartRoute>() is the type-safe way to ask "am I on this screen?". The setting persists only for this session. To keep it permanently you would use DataStore (lesson 35).

🎯 Your Challenge

Exercise 2: Clear the whole flow after ordering

After the user taps Send order and the share sheet closes, the Back button should not return to the summary. Make the app return to the Start screen with a clean order immediately after sending, using popBackStack inside the share callback.

kotlin
onSendButtonClicked = { subject, summary ->
 shareOrder(context, subject, summary)
 viewModel.resetOrder()
 navController.popBackStack(StartRoute, inclusive = false)
}

popBackStack(route, inclusive) removes entries until it reaches the given route. With inclusive = false the Start destination itself stays. Resetting the ViewModel first guarantees the next order starts empty. A product decision to consider: the user may want to see the confirmation, so many apps show a "Thank you" screen first and clear the stack from there.

✏️ Your Task

Draw the back stack on paper after each of these actions, starting from the app launch: choose six cupcakes, pick Vanilla, press the system Back, pick Chocolate, press Cancel. How many entries remain at each step?

Phase 11 of 12
Common Beginner Mistakes (and How to Fix Them)

Mistake #1: Passing the NavController into every screen. What happens: screens cannot be previewed or tested without a controller. The fix: give screens lambdas such as onNextButtonClicked and keep navigation in the host.

Mistake #2: Calling navigate() during composition. The wrong code: navController.navigate(FlavorRoute) as a bare statement in a composable. What happens: it runs again on every recomposition, causing repeated navigation or crashes. The fix: navigate only inside event callbacks (onClick). Lesson 55 covers LaunchedEffect for the rare cases that must navigate in response to state.

Mistake #3: Stacking duplicate destinations. What happens: tapping a bottom-bar item twice builds a growing stack of the same screen. The fix: navigate(route) { launchSingleTop = true } (lesson 27 uses this with bars).

Mistake #4: Forgetting to reset state on cancel. What happens: the next order starts with the old flavour. The fix: reset the ViewModel before or after popping.

Mistake #5: Putting big objects in routes. What happens: crashes or silent truncation, and state that cannot be restored. The fix: pass an ID and load data in the destination.

Mistake #6: Creating a new ViewModel per screen when you wanted to share. What happens: each screen sees an empty order. The fix: create the ViewModel once in the host and pass it, or scope it to a navigation graph with hiltViewModel/viewModel(parentEntry) (lesson 63).

Mistake #7: A String.format surprise. What happens: "a" + "b%d".format(1) formats only the last literal. The fix: use string templates or buildString and format the finished text.

Mistake #8: Testing route strings instead of visible behaviour. What happens: tests break when you rename a route. The fix: assert on screen content (onNodeWithText) or on hasRoute when you must check the destination.

✏️ Your Task
Practise what you just learned about Common Beginner Mistakes (and How to Fix Them). Open Android Studio, type the examples by hand, change a value, and observe what happens.
Phase 12 of 12
Knowledge Check

Question 1. What is the back stack?

The ordered list of destinations the user has visited. Navigating forward pushes a destination, and Back pops the top one.

Question 2. What are the roles of NavController and NavHost?

The NavController owns the back stack and performs navigate and popBackStack. The NavHost is the composable that displays the destination currently on top and holds the navigation graph.

Question 3. Why are type-safe routes preferred over string routes?

The compiler checks them, so typos and wrong argument types are caught at build time instead of crashing at runtime. They also make passing arguments simpler.

Question 4. How does popBackStack(StartRoute, inclusive = false) behave?

It removes all destinations above StartRoute and keeps StartRoute itself. With inclusive = true the start destination would be removed too.

Question 5. What does TestNavHostController let you do?

It is a controller designed for tests, letting you run the real navigation graph in a Compose UI test and verify that interactions lead to the expected destination.

Go deeper: official resources

✏️ Your Task
Practise what you just learned about Knowledge Check. Open Android Studio, type the examples by hand, change a value, and observe what happens.
🏗️ Build It — Mini Project
Three-Screen Quiz App

Project goal: Build a small quiz with Start, Question and Result screens, sharing a ViewModel, with a working Up button, a Play again button that clears the stack, and one navigation test.

Requirements:

  1. Routes: StartRoute, QuestionRoute, ResultRoute (all type-safe).
  2. A ViewModel holding five questions, the current index and the score.
  3. The Question screen shows one question with four answers. Choosing an answer scores it and moves on. After the last question, navigate to the Result screen.
  4. On the Result screen, Play again resets the ViewModel and pops back to Start. The Back button on Result must also go to Start, not into the questions.
  5. A test that taps Start and expects to see Question 1.

Stretch goal: show a progress indicator (LinearProgressIndicator) on the Question screen.

QuizApp.kt
package com.example.quiz
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.ViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavHostController
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.serialization.Serializable
@Serializable object StartRoute
@Serializable object QuestionRoute
@Serializable object ResultRoute
data class Question(val text: String, val answers: List<String>, val correctIndex: Int)
val questions = listOf(
 Question("Capital of Nigeria?", listOf("Lagos", "Abuja", "Ibadan", "Kano"), 1),
 Question("Which language builds modern Android apps?", listOf("Kotlin", "COBOL", "Pascal", "Perl"), 0),
 Question("2 + 2 x 3 = ?", listOf("12", "8", "10", "6"), 1),
 Question("How many states does Nigeria have?", listOf("30", "34", "36", "40"), 2),
 Question("HTML is a...", listOf("Database", "Markup language", "Compiler", "Browser"), 1)
)
data class QuizUiState(val index: Int = 0, val score: Int = 0)
class QuizViewModel : ViewModel() {
 private val _state = MutableStateFlow(QuizUiState())
 val state: StateFlow<QuizUiState> = _state.asStateFlow()
 /** Returns true when that was the last question. */
 fun answer(choice: Int): Boolean {
 val current = questions[_state.value.index]
 val finished = _state.value.index == questions.lastIndex
 _state.update { s ->
 s.copy(
 score = if (choice == current.correctIndex) s.score + 1 else s.score,
 index = if (finished) s.index else s.index + 1
 )
 }
 return finished
 }
 fun reset() { _state.value = QuizUiState() }
}
@Composable
fun QuizApp(
 viewModel: QuizViewModel = viewModel(),
 navController: NavHostController = rememberNavController()
) {
 val state by viewModel.state.collectAsStateWithLifecycle()
 NavHost(navController, startDestination = StartRoute) {
 composable<StartRoute> {
 Column(Modifier.padding(24.dp)) {
 Text("Quiz time")
 Button(onClick = { navController.navigate(QuestionRoute) }) { Text("Start") }
 }
 }
 composable<QuestionRoute> {
 val q = questions[state.index]
 Column(Modifier.padding(24.dp)) {
 Text("Question ${state.index + 1}")
 LinearProgressIndicator(
 progress = { (state.index + 1) / questions.size.toFloat() },
 modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp)
 )
 Text(q.text)
 q.answers.forEachIndexed { i, answer ->
 Button(
 onClick = {
 if (viewModel.answer(i)) {
 navController.navigate(ResultRoute) {
 popUpTo(StartRoute) // drop the question screen
 }
 }
 },
 modifier = Modifier.fillMaxWidth().padding(top = 4.dp)
 ) { Text(answer) }
 }
 }
 }
 composable<ResultRoute> {
 BackHandler {
 viewModel.reset()
 navController.popBackStack(StartRoute, inclusive = false)
 }
 Column(Modifier.padding(24.dp)) {
 Text("Score: ${state.score} / ${questions.size}")
 Button(onClick = {
 viewModel.reset()
 navController.popBackStack(StartRoute, inclusive = false)
 }) { Text("Play again") }
 }
 }
 }
}
QuizNavigationTest.kt
package com.example.quiz
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import org.junit.Rule
import org.junit.Test
class QuizNavigationTest {
 @get:Rule val rule = createComposeRule()
 @Test
 fun tappingStart_showsQuestionOne() {
 rule.setContent { QuizApp() }
 rule.onNodeWithText("Start").performClick()
 rule.onNodeWithText("Question 1").assertExists()
 }
}

Notes: popUpTo(StartRoute) in the navigate options removes everything above Start before pushing the Result, so Back from the result goes to Start instead of re-answering questions. The BackHandler is a belt-and-braces measure that also resets the quiz. The test uses the default rememberNavController(); the lesson's TestNavHostController version is needed only when you want to inspect the controller itself. As the question screen index never goes past the last question (the ViewModel clamps it), there is no out-of-range crash after the final answer while navigation is in flight.

1

Open your finished project in Android Studio. Check that the .gitignore file the wizard created is in the project root. It keeps local.properties, build folders and keystores out of Git. Never commit passwords or signing keys.

2

Choose Git > Create Git Repository… (or VCS > Enable Version Control Integration in older versions), select the project folder, then open Git > Commit, tick every file, and write the message “Add Android lesson 26 project”.

3

On github.com click New repository, name it android-lesson-26-navigation-in-jetpack-compose-the-cupcake, set it to Public, leave Add a README unticked, and click Create repository.

4

Back in Android Studio choose Git > Manage Remotes…, add the repository URL as origin, then choose Git > Push… and push your main branch. Prefer the terminal? Run git remote add origin <your-url> then git push -u origin main.

5

Add a README.md with a screenshot of your app running and a short description. Employers and clients read READMEs first. Your project is now part of your public portfolio. 🎉

Lesson 26 complete! 🎉

You covered:

  • Lesson Introduction
  • Prerequisite Concepts
  • Conceptual Understanding: The Back Stack
  • Step-by-Step Build 1: Set Up Navigation
  • Step-by-Step Build 2: The Order State
  • Step-by-Step Build 3: The Four Screens
  • Step-by-Step Build 4: The NavHost
  • Passing Data Through the Route
  • Testing the Navigation
  • Guided Practice Exercises
  • Common Beginner Mistakes (and How to Fix Them)
  • Knowledge Check
All 72 lessons in Google Android Studio ·Techbase Academy ·© 2025–2026 Techbase Consultant Services, Ibadan
Esc
↑ ↓ to moveEnter to openEsc to close