Android · Type it from memory

30-minute build template

One short EP08 build to drill until it runs by minute 21, the concepts behind it, and the same pattern against a real HTTP API.

Updated October 12, 2026 · Original practice material, written for David Chung’s preparation. It is not Eight Sleep’s actual assessment.

Part 1 · 30-minute build template (EP08)

Original practice material. Builds EP08; not Eight Sleep’s actual assessment.

19 sections
TP00Libraries

Add the libraries before the timer starts and sync Gradle. Open gradle/libs.versions.toml first and read its [versions] block: template versions name things differently, and a version.ref to a name that does not exist fails the sync with "Undefined version reference".

Version catalog

TOML

# gradle/libs.versions.toml
[versions]
# Reuse the lifecycle version the template already declares. Newer templates call it
# lifecycle, older ones lifecycleRuntimeKtx. Add coroutines only if it is not there yet.
coroutines = "1.9.0"

[libraries]
androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycle" }
androidx-lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycle" }
kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "coroutines" }

Kotlin

// app/build.gradle.kts, inside dependencies { }
implementation(libs.androidx.lifecycle.viewmodel.compose)
implementation(libs.androidx.lifecycle.runtime.compose)
testImplementation(libs.kotlinx.coroutines.test)

If the sync fails and the clock is running, skip the catalog: these three lines in app/build.gradle.kts need no [versions] entries and always resolve. Move them into the catalog afterwards.

Kotlin

// app/build.gradle.kts, inside dependencies { }: the fallback
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.9.0")
implementation("androidx.lifecycle:lifecycle-runtime-compose:2.9.0")
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.9.0")

The TP07 build used lifecycle 2.9.0, coroutines 1.9.0 and Kotlin 2.1.20, in both forms.

Imports

Turn on auto-import: Settings, Editor, General, Auto Import, then under Kotlin check "Add unambiguous imports on the fly". Alt+Enter covers the rest. TP04 lists the four imports it gets wrong or misses.

Only for TP17, multiple screens

TOML

# gradle/libs.versions.toml
[versions]
# kotlin is already in the template's [versions]; check the name before using it below.
navigationCompose = "2.8.9"

[libraries]
androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigationCompose" }

[plugins]
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }

Kotlin

// build.gradle.kts (root), inside plugins { }
alias(libs.plugins.kotlin.serialization) apply false

// app/build.gradle.kts
plugins {
    alias(libs.plugins.kotlin.serialization)
}
dependencies {
    implementation(libs.androidx.navigation.compose)
}
TP01Drill loop

Type the code below from memory into a fresh Empty Activity project until the core runs by minute 21. The files are listed in typing order.

  • 0 to 3, clarify. Restate the behavior and ask what is already supplied.
  • 3 to 6, model. Say the state shape and the repository boundary aloud while typing Night and SleepUiState.
  • 6 to 21, implement. Fake repository, then ViewModel, then screen, then wire it into MainActivity.
  • 21 to 26, run. Show DATA, then ERROR and Retry, then EMPTY.
  • 26 to 30, explain. State ownership, one tradeoff, one next step.

Add three dependencies before the timer starts, because the Empty Activity template does not include them: lifecycle-viewmodel-compose, lifecycle-runtime-compose, and kotlinx-coroutines-test for the test.

TP021. Model and fake repository

The fake waits 600 ms and serves ERROR or EMPTY once, then DATA, which is the behavior EP08 specifies.

Kotlin

// data class: the compiler generates equals/hashCode (compare by value),
// copy() and toString(). Value equality is what lets StateFlow and Compose
// see "nothing changed" and skip work.
data class Night(val id: String, val date: String, val durationMinutes: Int)

// suspend: this function can pause without blocking its thread, so it can
// only be called from a coroutine or from another suspend function.
// The interface starts no coroutine. The caller does.
interface SleepRepository {
    suspend fun loadNights(): List<Night>
}

enum class SampleResponse { DATA, ERROR, EMPTY }

// Uses coroutines only through suspend + delay. It never calls launch or
// async: the ViewModel's viewModelScope.launch creates the coroutine, and
// loadNights() runs inside it.
class FakeSleepRepository(private var next: SampleResponse = SampleResponse.DATA) : SleepRepository {
    override suspend fun loadNights(): List<Night> {
        // delay() suspends for 600 ms and frees the thread; Thread.sleep()
        // would block it. delay() is also a cancellation point: if the
        // ViewModel is cleared mid-wait, it throws CancellationException here.
        delay(600)
        val response = next
        next = SampleResponse.DATA // ERROR and EMPTY are served once, then DATA
        return when (response) {
            SampleResponse.DATA -> listOf(
                Night("N1", "2026-10-07", 435),
                Night("N2", "2026-10-06", 400),
                Night("N3", "2026-10-05", 480),
            )
            SampleResponse.EMPTY -> emptyList()
            SampleResponse.ERROR -> throw IOException("Offline")
        }
    }
}

The same repository against a real HTTP endpoint: see Part 2, Real API version.

TP032. State and ViewModel

One flat state and one load() cover the first load, Retry and the duplicate-tap guard.

Kotlin

data class SleepUiState(
    val isLoading: Boolean = false,
    val nights: List<Night> = emptyList(),
    val error: String? = null,
)

class SleepViewModel(private val repository: SleepRepository) : ViewModel() {
    private val _state = MutableStateFlow(SleepUiState())
    val state: StateFlow<SleepUiState> = _state.asStateFlow()
    private var loadJob: Job? = null

    init { load() }

    fun load() {
        if (loadJob?.isActive == true) return
        _state.update { it.copy(isLoading = true, error = null) }
        loadJob = viewModelScope.launch {
            try {
                val nights = repository.loadNights()
                _state.update { it.copy(isLoading = false, nights = nights) }
            } catch (e: IOException) {
                _state.update { it.copy(isLoading = false, error = "Could not load sleep history.") }
            }
        }
    }
}

Loading is set before launch, so the state already reads loading when the first collector arrives and when a second tap lands.

TP043. Screen

The screen gets the ViewModel, collects its state and chooses one of four branches, with the list checked first.

Kotlin

@Composable
fun SleepScreen(
    modifier: Modifier = Modifier,
    viewModel: SleepViewModel = viewModel { SleepViewModel(FakeSleepRepository()) },
) {
    val state by viewModel.state.collectAsStateWithLifecycle()
    Box(modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
        when {
            state.nights.isNotEmpty() -> LazyColumn(Modifier.fillMaxSize()) {
                items(state.nights, key = { it.id }) { night ->
                    ListItem(
                        headlineContent = { Text(night.date) },
                        supportingContent = { Text(formatDuration(night.durationMinutes)) },
                    )
                }
            }
            state.isLoading -> CircularProgressIndicator()
            state.error != null -> Column(horizontalAlignment = Alignment.CenterHorizontally) {
                Text(state.error.orEmpty()) // state is delegated (by), so no smart cast
                Button(onClick = { viewModel.load() }) { Text("Retry") }
            }
            else -> Text("No sleep history yet")
        }
    }
}

fun formatDuration(minutes: Int): String = "${minutes / 60} h ${minutes % 60} min"

In MainActivity, keep the generated theme and Scaffold, and replace the Greeting call with SleepScreen(Modifier.padding(innerPadding)). To demo the other modes, pass SampleResponse.ERROR or SampleResponse.EMPTY to FakeSleepRepository in the screen's viewModel default and rerun.

Text(state.error) does not compile here: state is a delegated property (by), so Kotlin cannot smart-cast state.error to String. orEmpty() avoids the error.

Four imports that auto-import often gets wrong or misses:

  • androidx.compose.foundation.lazy.items: without it, items(state.nights) resolves to the items(count: Int) overload and fails with a type mismatch.
  • androidx.compose.runtime.getValue: required for by.
  • androidx.lifecycle.compose.collectAsStateWithLifecycle
  • androidx.lifecycle.viewmodel.compose.viewModel
TP054. One test

Error then Retry is the one test worth typing, because it proves the failure path, the reset and the reload on virtual time.

Kotlin

@OptIn(ExperimentalCoroutinesApi::class)
class SleepViewModelTest {
    @Before fun setUp() { Dispatchers.setMain(StandardTestDispatcher()) }
    @After fun tearDown() { Dispatchers.resetMain() }

    @Test fun errorThenRetryShowsData() = runTest {
        val viewModel = SleepViewModel(FakeSleepRepository(SampleResponse.ERROR))
        advanceUntilIdle()
        assertNotNull(viewModel.state.value.error)

        viewModel.load()
        assertTrue(viewModel.state.value.isLoading)
        advanceUntilIdle()

        assertEquals(3, viewModel.state.value.nights.size)
        assertNull(viewModel.state.value.error)
    }
}

Skip this in the room unless the feature already runs and you have minutes left; describe it instead.

TP06Say this

These six lines carry the explanation; close on the last one.

  • Ownership. "The ViewModel owns the request and the state. The screen renders that state and sends Retry back. Loading starts in init, so recomposition never starts a request."
  • Flat state. "One data class lets the list stay on screen during a refresh. The cost is that impossible combinations can be written, so every write goes through load()."
  • Duplicate taps. "A second call returns while the job is active, so two taps make one request."
  • Errors. "I catch only IOException. Cancellation and programming errors propagate. If I widened it to Exception, I would rethrow CancellationException first."
  • Lifetime. "Rotation keeps the ViewModel. A new process reloads. Surviving that would need Room, which this brief does not ask for."
  • Tradeoff. "A failed refresh would currently hide behind the list. Next I would show it as a banner and decide which result wins if refreshes overlap."
TP07What was checked

Sections 1 and 2 and the duration formatter compiled with Kotlin 2.1.20 and passed nine state checks on 2026-10-11, using a stand-in for ViewModel and viewModelScope. The checks covered DATA, ERROR then Retry, EMPTY, the three durations, and repeated load() calls making one repository call.

On 2026-10-11, sections 1 to 4 were compiled byte for byte in a real Android project with Kotlin 2.1.20, AGP 8.12.0, Gradle 8.13, JDK 17, compileSdk 35, Compose BOM 2025.04.01 (Material 3 1.3.2), lifecycle-viewmodel-compose and lifecycle-runtime-compose 2.9.0, kotlinx-coroutines and kotlinx-coroutines-test 1.9.0, and JUnit 4.13.2. Only a package line, imports and a MainActivity calling SleepScreen(Modifier.padding(innerPadding)) were added. The debug build succeeded with no compiler warnings, the section 4 test errorThenRetryShowsData passed, and on a Pixel 4a emulator the app showed the three nights as 7 h 15 min, 6 h 40 min and 8 h 0 min. Retry was not tapped on the emulator; the test covers that path.

TP17's two-screen app was compiled the same way, adding navigation-compose 2.8.9 and the Kotlin serialization plugin 2.1.20. Its first and third blocks appear byte for byte in that app, and each excerpt in its second block does too. On the emulator, tapping 2026-10-06 opened a detail screen showing 6 h 40 min, and both the Back button and the system back gesture returned to the list.

TP00 lists the libraries from that build, in version-catalog form, which was also built. The short snippets in TP12, TP13 and TP16 are excerpts and were not compiled on their own.

TP08Coroutines and parallelism notes

From a walkthrough — the senior-level points to have ready.

  • Suspend vs block. A suspend function releases the thread when it hits something slow and resumes later. It never holds a thread while waiting, so the thread stays free for other work.
  • Why not threads. A thread costs about a megabyte; thousands exhaust memory. Coroutines are cheap because a suspended one frees its thread, so a few threads juggle thousands of coroutines.
  • Parallel fan-out. Start every request with async first, collecting the Deferred handles into a list, then awaitAll. Total time is the slowest request, not the sum.
  • The trap. async { fetch() }.await() inside the loop serializes it again — each request finishes before the next starts. The fix is two phases: fire all, then await all.
  • Why the list. The sequential version keeps no handles because each job finishes in place. The parallel version holds every handle at once precisely because none are done yet — holding the handle is the same as not yet waiting. The cost of parallelism is that bookkeeping.
  • Bounding. On a large or unbounded list, cap how many run at once rather than starting a million coroutines. Worth saying aloud in an interview.
TP09Unidirectional data flow

One loop: events go in, state comes back

Top arrows are calls going in. Bottom arrows are results coming back.

Three boxes from left to right: SleepScreen, SleepViewModel, SleepRepository. Calls go right: SleepScreen calls viewModel.load() on SleepViewModel when Retry is tapped, and SleepViewModel calls loadNights() on SleepRepository. Results come back left: SleepRepository returns nights or IOException to SleepViewModel, and SleepViewModel’s StateFlow emits the new SleepUiState to SleepScreen. The repository only talks to the ViewModel.SleepScreencollects StateFlowrenders the stateSleepViewModelMutableStateFlowwritten only hereSleepRepositorysuspend loadNights()fake or networkviewModel.load()StateFlow emitsloadNights()nights or IOException

Swipe sideways to see all three boxes.

Read it as a loop: an event (Retry) calls load() on the ViewModel, which fetches through the repository and writes a new SleepUiState into the MutableStateFlow. That state flows back to the screen, which collects it, and only the composables that read the changed value recompose.

TP10Flows, state and recomposition notes

The streaming and UI-state points, with the traps that trip people up.

  • Suspend vs flow. A suspend function returns one value, eventually. A flow is a stream of many values over time. Something emits, something collects; collecting suspends, so it runs inside a coroutine.
  • Cold vs hot. A cold flow is lazy — it restarts from scratch for each collector, and nothing runs with no collector. A hot flow emits regardless and all collectors share one stream.
  • StateFlow. The hot flow the UI uses: always holds exactly one current value. A new collector gets that value immediately, then every update after.
  • One-way ownership. The ViewModel holds a MutableStateFlow it can write, and exposes a read-only StateFlow. State changes in exactly one place. Events go up (function calls), state flows down.
  • Recomposition scoping. Compose records which composable read which value, and re-runs only those whose inputs changed. Nested rows recompose independently. Stable types (a plain data class) and stable list keys (key = { it.id }) are what let it skip unchanged ones.
  • remember. A composable re-runs constantly, so a plain variable is recreated every time. remember computes once and keeps the value across recompositions. rememberSaveable also survives rotation and process death.
  • remember { mutableStateOf(...) }. mutableStateOf makes it observable so a change triggers recomposition; remember keeps it alive between them. The standard local UI-state pattern.
  • ViewModel vs remember. The test: would anyone miss this value in a unit test, after rotation, or elsewhere in the app? Yes → ViewModel (the sleep list, loading, error). Purely a visual detail of this one widget now → remember (expanded, scroll, focus).
  • The LazyColumn remember trap. A row's remember dies when the row scrolls off screen, so its expanded flag resets on scroll-back. If expansion must persist, hoist it to the ViewModel keyed by row ID. LazyColumn only composes the rows on screen, so you never hold a thousand live states anyway.
TP11More common Android traps

The ones interviewers return to, beyond flows and state.

  • Wrong coroutine scope. Launch in the scope tied to the right lifecycle: viewModelScope in a ViewModel (cancels when it dies), rememberCoroutineScope or LaunchedEffect in Compose. Never GlobalScope — that work leaks and keeps running after the screen is gone.
  • Side effects in the composable body. A composable recomposes many times a second, so a network call written directly in the body fires over and over. Put side effects in LaunchedEffect, which runs once and re-runs only when its key changes.
  • Collecting a flow without lifecycle awareness. A naive collect keeps running in the background, wasting battery. collectAsStateWithLifecycle stops when the screen isn't visible — already used in the template.
  • Blocking the main thread. Disk, network, or heavy parsing on the main thread freezes the UI (network on main crashes outright). Push it off with withContext(Dispatchers.IO).
  • Context / View leaks. Holding an Activity or View past its life — classically a static reference — stops the old Activity being garbage collected after rotation. Use application context for long-lived things; never hold a View in a ViewModel.
  • Catching CancellationException. A broad catch (e: Exception) swallows the cancellation signal and breaks structured concurrency. Catch narrowly (IOException), or rethrow CancellationException first.
TP12Where work starts: init vs LaunchedEffect, and the loading order

Two separate places work can begin — don't blend them.

  • LaunchedEffect is a Compose thing. It lives in the UI and fires a side effect when the screen appears (runs once on first composition, re-runs when its key changes). For effects triggered by the UI showing up.
  • The ViewModel's load() is plain Kotlin, not Compose. It's kicked off in the init block when the ViewModel is created, so the initial load needs no LaunchedEffect — the ViewModel loads itself the moment it's born.

The order inside load():

Kotlin

fun load() {
    _state.update { it.copy(isLoading = true, error = null) }   // before launch
    viewModelScope.launch {
        val nights = repository.loadNights()                    // suspend call
        _state.update { it.copy(isLoading = false, nights = nights) }
    }
}
  • Why loading goes before launch, not inside. Setting it first makes the write synchronous whatever the dispatcher. viewModelScope runs on Dispatchers.Main.immediate, so from the main thread the block usually starts at once, but under a test dispatcher or any non-immediate dispatcher it is queued. In that gap the state would still say not-loading: the spinner flickers, and a double tap can slip through. Set before launch, the state reads loading the instant the function returns.
  • The state write is _state.update { it.copy(...) } — update reads the current value and replaces it atomically; copy keeps the other fields.
TP13The backing property and updating state

Two properties for one piece of state — the underscore convention, and how to write a new value.

Kotlin

private val _state = MutableStateFlow(SleepUiState())   // mutable, private
val state: StateFlow<SleepUiState> = _state.asStateFlow() // read-only, public
  • Why two. _state is the MutableStateFlow the ViewModel writes to. state is the read-only StateFlow the screen sees. The underscore just marks the private mutable backing field. asStateFlow() strips off the mutable part, so nothing outside the ViewModel can change state — that is the one-way rule enforced in code.
  • .value directly. _state.value = _state.value.copy(isLoading = true) works: read current, copy with the change, assign back. Risk: two near-simultaneous writes can clobber each other (a race).
  • update instead. _state.update { it.copy(isLoading = true) } applies the read-modify-write atomically, so it is safe under concurrency and reads cleaner. it is the current state; copy keeps the other fields. Say in the room: "I use update over setting .value so the read-modify-write is atomic."
TP14Why a data class for state

data gives three things automatically; one is critical for Compose.

  • Value equality (equals). A data class is equal when all fields match; a regular class is equal only if it is the same object in memory. This is what lets Compose skip recomposition — if the new state equals the old, nothing redraws. A regular class looks different every time, so Compose redraws constantly.
  • copy. Enables it.copy(isLoading = true) for clean immutable updates; a regular class would force you to rebuild the whole object by hand.
  • toString / hashCode for free — handy for logging and debugging.
  • One-liner: "Data class because value equality lets Compose skip unchanged recompositions, and copy makes immutable updates clean."
  • Not the same as list keys: key = { it.id } in a LazyColumn is about matching rows by identity across changes — a different concept from data-class equality.
TP15LazyColumn vs Column — the senior answer

The distinction is composition cost, not just what's drawn.

  • Column composes and lays out every child up front. A thousand rows means a thousand composables built immediately — slow first frame, and the whole list sits in memory whether or not it's on screen. Fine for a handful of items; wrong for an unbounded list.
  • LazyColumn composes only what's visible plus a small buffer, and as you scroll it composes incoming rows and discards outgoing ones — the same recycling idea as RecyclerView, which is its lineage. Cost scales with the viewport, not the data, so it handles hundreds of nights or an effectively infinite list.
  • The cost it adds back. Laziness isn't free: without stable key = { it.id }, a list change forces Compose to re-match rows by position and over-recompose; without contentType on mixed row types, it can't reuse compositions across types. So the senior framing is: LazyColumn trades eager layout for per-item bookkeeping, and you pay that bookkeeping back with stable keys and content types.
  • Ties to earlier: because off-screen rows are discarded, a row-level remember dies on scroll — persistent per-row state (expanded) must be hoisted to the ViewModel keyed by ID.
  • One-liner: "Column is eager and O(n) to compose; LazyColumn is viewport-bound like RecyclerView, so it scales — provided you give it stable keys so it recomposes only what changed."
TP16Cold vs hot flow, through one API call

The same network call shows up cold or hot depending on what you do with it.

1. Plain suspend function — one shot, no flow.

Kotlin

suspend fun fetchNights(): List<Night>   // call it once, hits network, returns, done

2. Cold flow — re-runs the call for every collector.

Kotlin

fun nightsStream(): Flow<List<Night>> = flow { emit(fetchNights()) }

The flow { } builder is cold: the network call does not fire when the flow is created — only when someone collects. Two collectors (list + header both collecting nightsStream()) → the block runs twice → two identical API calls for the same data. A collector that leaves and returns triggers another call. Each collection is a fresh, independent hit.

3. Hot — collect once in the ViewModel, share with stateIn.

Kotlin

val nights = nightsStream().stateIn(
    viewModelScope,
    SharingStarted.WhileSubscribed(5000),
    emptyList()
)

Now nights is a hot StateFlow: the call happens once upstream while anyone is collecting, and both the list and the header read the one shared result. It keeps the last value, so a collector that returns within the 5-second window (a rotation) gets it with no new call. After 5 seconds with no collectors the upstream stops, and the next collector sees the cached value at once while the flow restarts and fetches again.

The arc: suspend function = one-shot · cold flow = re-runs per collector · stateIn = hot, one call shared by all. Rule of thumb: data sources (Room, network) are cold; the ViewModel converts them to hot state with stateIn/shareIn so the UI shares one stream.

Cold mental test: "does nothing until someone collects, and restarts for each collector?" → cold. Room queries, network calls, file reads. A StateFlow already holding a value whether or not anyone collects → hot.

TP17Multiple screens: routes and navigation

When the feature needs a second screen, add Navigation Compose: tap a night to open its detail, and Back returns to the list. Add navigation-compose 2.8.0 or newer and the Kotlin serialization plugin, the same plugin setup as TA01.

Kotlin

// One @Serializable type per destination. The data class carries the argument.
@Serializable object SleepList
@Serializable data class NightDetail(val id: String)

// MainActivity calls SleepNavHost(Modifier.padding(innerPadding)) instead of SleepScreen.
@Composable
fun SleepNavHost(modifier: Modifier = Modifier) {
    val navController = rememberNavController()
    NavHost(navController, startDestination = SleepList, modifier = modifier) {
        composable<SleepList> {
            SleepScreen(onNightClick = { id -> navController.navigate(NightDetail(id)) })
        }
        composable<NightDetail> {
            NightScreen(onBack = { navController.popBackStack() })
        }
    }
}

Each composable<...> block is one destination, and a route is the @Serializable type you navigate to. The screens never see navController: SleepScreen only reports a tapped ID through onNightClick, and the NavHost decides where that leads.

Kotlin

// SleepScreen gains one parameter...
fun SleepScreen(
    onNightClick: (String) -> Unit = {},
    modifier: Modifier = Modifier,
    viewModel: SleepViewModel = viewModel { SleepViewModel(FakeSleepRepository()) },
)

// ...and each row reports its ID when tapped.
ListItem(
    headlineContent = { Text(night.date) },
    supportingContent = { Text(formatDuration(night.durationMinutes)) },
    modifier = Modifier.clickable { onNightClick(night.id) },
)

// The repository gains a lookup by ID.
interface SleepRepository {
    suspend fun loadNights(): List<Night>
    suspend fun loadNight(id: String): Night?
}

// In FakeSleepRepository:
override suspend fun loadNight(id: String): Night? = loadNights().find { it.id == id }

The detail screen repeats the template's pattern: state, ViewModel and screen.

Kotlin

data class NightUiState(
    val isLoading: Boolean = true,
    val night: Night? = null,
    val error: String? = null,
)

class NightViewModel(savedStateHandle: SavedStateHandle, private val repository: SleepRepository) : ViewModel() {
    private val id = savedStateHandle.toRoute<NightDetail>().id // the ID from the route
    private val _state = MutableStateFlow(NightUiState())
    val state: StateFlow<NightUiState> = _state.asStateFlow()

    init {
        viewModelScope.launch {
            try {
                val night = repository.loadNight(id)
                _state.update { it.copy(isLoading = false, night = night, error = if (night == null) "Night not found." else null) }
            } catch (e: IOException) {
                _state.update { it.copy(isLoading = false, error = "Could not load this night.") }
            }
        }
    }
}

@Composable
fun NightScreen(
    onBack: () -> Unit,
    modifier: Modifier = Modifier,
    viewModel: NightViewModel = viewModel { NightViewModel(createSavedStateHandle(), FakeSleepRepository()) },
) {
    val state by viewModel.state.collectAsStateWithLifecycle()
    val night = state.night // a plain local, so it smart-casts below
    Column(modifier.fillMaxSize().padding(16.dp)) {
        TextButton(onClick = onBack) { Text("Back") }
        when {
            night != null -> {
                Text(night.date, style = MaterialTheme.typography.headlineSmall)
                Text(formatDuration(night.durationMinutes))
            }
            state.isLoading -> CircularProgressIndicator()
            else -> Text(state.error ?: "Night not found.")
        }
    }
}
  • Each destination owns its ViewModel. viewModel { } inside a composable<...> destination is scoped to that back-stack entry. NightViewModel is cleared when you go back, and the list's ViewModel survives while the detail is open, so Back does not reload.
  • Pass the ID, not the object. Route arguments are saved with the back stack, so keep them small. The detail screen loads its own data, which is also how a deep link would work.
  • The ID's path. The row calls onNightClick(night.id), the NavHost's lambda turns it into NightDetail(id), and NightViewModel reads it back with savedStateHandle.toRoute<NightDetail>().
  • Smart cast. NightScreen copies state.night into a plain local, night, because a delegated state cannot be smart-cast.
  • When to add a route layer. As screens grow, split each into a stateful XxxRoute that gets the ViewModel and collects state, and a stateless XxxScreen that takes plain state and lambdas, so it can be previewed and tested without a ViewModel. The NavHost then calls the routes.
  • Two fakes. Each screen creates its own FakeSleepRepository here. In a real app one repository instance, usually provided by Hilt, backs both.
  • This fake's quirk. loadNight reuses loadNights(), so it also waits 600 ms and would use up a one-time ERROR.

Say this: "Each destination gets its own ViewModel scoped to its back-stack entry. I pass only the ID in the route, and the detail ViewModel reads it from SavedStateHandle and loads its own data."

TP18Dependency injection: interview question

Question: "What is dependency injection, and why would you use it here?"

The template already uses it: SleepViewModel takes a SleepRepository in its constructor instead of building one. That is constructor injection. A DI framework is only a way of doing the wiring.

Kotlin

// Without DI: the ViewModel picks its own repository. You can't swap it or test it.
class SleepViewModel : ViewModel() {
    private val repository = NetworkSleepRepository(RetrofitClient.api)
}

// With DI: the caller decides. Fake in tests and in the template, network in the app.
class SleepViewModel(private val repository: SleepRepository) : ViewModel()

Manual DI: a container you own

Kotlin

class AppContainer {
    // One instance for the whole app. Swap in NetworkSleepRepository(api) for the real app.
    val sleepRepository: SleepRepository by lazy { FakeSleepRepository() }
}

class SleepApp : Application() {
    val container = AppContainer()
}

XML

<!-- AndroidManifest.xml -->
<application android:name=".SleepApp" ...>

Kotlin

// MainActivity, inside the Scaffold
Scaffold(
    modifier = Modifier.fillMaxSize(),
    topBar = { TopAppBar(title = { Text("Sleep history") }) },
) { innerPadding ->
    val app = LocalContext.current.applicationContext as SleepApp
    SleepScreen(
        modifier = Modifier.padding(innerPadding),
        viewModel = viewModel { SleepViewModel(app.container.sleepRepository) },
    )
}

Read LocalContext outside the viewModel { } lambda: it is a composable read, and the lambda is not composable. TopAppBar needs @OptIn(ExperimentalMaterial3Api::class) on onCreate or the class.

Hilt: the same wiring, generated

Kotlin

@HiltViewModel
class SleepViewModel @Inject constructor(private val repository: SleepRepository) : ViewModel()

@Module @InstallIn(SingletonComponent::class)
abstract class DataModule {
    @Binds @Singleton abstract fun bindRepo(impl: NetworkSleepRepository): SleepRepository
}

// In the screen
viewModel: SleepViewModel = hiltViewModel()

Hilt also needs its Gradle plugin, KSP, @HiltAndroidApp on the Application and @AndroidEntryPoint on the Activity, and NetworkSleepRepository needs an @Inject constructor.

Answer

Say this: "DI means a class declares what it needs instead of building it. The main benefit is testability: my ViewModel test passes a fake repository with no network and no mocking framework. It also lets me swap implementations, fake for network or staging for prod, without touching the ViewModel. Lifetimes live in one place, so there's one OkHttp client and one database for the whole app, and the dependency graph is explicit."

Then the trade-off: "Manual DI has no library and no magic, and it's ideal for a small app or a 30-minute exercise, but the container grows by hand and I manage scopes myself. Hilt removes that boilerplate, handles ViewModel and SavedStateHandle injection, and Dagger checks the graph at compile time. In return it adds annotation processing and build time, a learning curve, and errors that can be hard to read. Here I'd use constructor injection with a viewModel { } factory. In a production app with several features I'd use Hilt. Whichever I pick, the important part is constructor injection: the framework is just how the wiring gets done."

ChoiceManual DIHilt
SetupNoneGradle plugin, KSP, annotations
BoilerplateGrows with every dependencyGenerated
Graph errorsAt runtimeAt compile time
Build timeNo costAnnotation processing
Best forSmall apps, interviewsMulti-feature production apps

Follow-up

"Why not make the repository a singleton object?" "It's global hidden state. Tests can't replace it, and I don't control its lifetime. With DI I still get one instance, because the container or @Singleton makes it, but the ViewModel only sees the interface."

What was checked

On 2026-10-12 the manual DI block, the manifest line and the MainActivity block were compiled in the Part 1 project and run on a Pixel emulator: the Sleep history top bar showed with the three nights below it, not under it. The "Without DI" and Hilt snippets are illustrations and were not compiled.

Part 2 · Real API version

Original practice material. Uses the EP02 endpoint; not Eight Sleep’s actual assessment. Back to Part 1.

This swaps the fake repository for a real HTTP call with Retrofit, and the ViewModel and screen keep the template's shape. No public sleep API exists, so it calls DummyJSON products, the endpoint EP02 uses.

5 sections
TA01Setup

Add these before the timer starts, or ask what the interview project already includes. As in TP00, read the [versions] block of gradle/libs.versions.toml first: the template already declares kotlin, and every version.ref has to match a name that exists there.

Version catalog

TOML

# gradle/libs.versions.toml
[versions]
# kotlin is already in the template. Read its value to pick kotlinxSerialization (see below).
retrofit = "3.0.0"
kotlinxSerialization = "1.8.1"

[libraries]
retrofit-core = { group = "com.squareup.retrofit2", name = "retrofit", version.ref = "retrofit" }
retrofit-kotlinx-serialization = { group = "com.squareup.retrofit2", name = "converter-kotlinx-serialization", version.ref = "retrofit" }
kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinxSerialization" }

[plugins]
# Skip this line if you already added it for TP17.
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }

Kotlin

// build.gradle.kts (root), inside plugins { }
alias(libs.plugins.kotlin.serialization) apply false

// app/build.gradle.kts, inside plugins { }
alias(libs.plugins.kotlin.serialization)

// app/build.gradle.kts, inside dependencies { }
implementation(libs.retrofit.core)
implementation(libs.retrofit.kotlinx.serialization)
implementation(libs.kotlinx.serialization.json)

XML

<!-- AndroidManifest.xml, above <application> -->
<uses-permission android:name="android.permission.INTERNET" />

The serialization plugin takes the template's kotlin version through version.ref, and the JSON library has to match it too: 1.11.0 is built against Kotlin 2.3.20, 1.10.0 against 2.3.0, and 1.9.0 requires Kotlin 2.2. For the workshop's Kotlin 2.1.20, use a 1.8.x release and confirm it in the release notes.

Sources: Retrofit releases · kotlinx.serialization releases

TA021. API, DTO and mapping

The DTOs mirror the JSON, the domain model is what the UI uses, and Retrofit generates the HTTP code from the interface.

Kotlin

// DTOs mirror the JSON. @Serializable makes the plugin generate the parser.
@Serializable
data class ProductsResponse(val products: List<ProductDto>)

@Serializable
data class ProductDto(val id: Int, val title: String, val description: String, val price: Double)

// Domain model: what the ViewModel and screen use. No serialization annotations.
data class Product(val id: Int, val title: String, val description: String, val price: Double)

fun ProductDto.toDomain() = Product(id, title, description, price)

// Retrofit generates the implementation. suspend makes the call main-safe and
// cancellable: the request runs on OkHttp's threads, not the caller's.
interface ProductApi {
    @GET("products")
    suspend fun products(@Query("limit") limit: Int): ProductsResponse
}

// DummyJSON sends many more fields than the DTO declares, so ignore unknown keys.
private val json = Json { ignoreUnknownKeys = true }

val productApi: ProductApi = Retrofit.Builder()
    .baseUrl("https://dummyjson.com/") // must end with a slash
    .addConverterFactory(json.asConverterFactory("application/json".toMediaType()))
    .build()
    .create(ProductApi::class.java)

The DTO and the domain model are identical today. They are split so a renamed JSON field changes one mapper and nothing in the UI. If minutes are short, use the DTO directly and say that you would split it.

Imports that are easy to miss:

  • retrofit2.converter.kotlinx.serialization.asConverterFactory
  • okhttp3.MediaType.Companion.toMediaType
  • kotlinx.serialization.Serializable and kotlinx.serialization.json.Json
  • retrofit2.http.GET and retrofit2.http.Query
TA032. Repository and wiring

Only the repository is new code; the state, ViewModel and screen are the template's with the names changed.

Kotlin

interface ProductRepository {
    suspend fun loadProducts(): List<Product>
}

class NetworkProductRepository(private val api: ProductApi) : ProductRepository {
    // No withContext(Dispatchers.IO): a Retrofit suspend call is already main-safe.
    override suspend fun loadProducts(): List<Product> =
        try {
            api.products(limit = 20).products.map { it.toDomain() }
        } catch (e: HttpException) {
            // A 404 or 500 is not an IOException. Convert it so the ViewModel's
            // single catch (e: IOException) still shows Retry.
            throw IOException("HTTP ${e.code()}", e)
        }
}

// In the screen, the one line that changes:
viewModel: ProductViewModel = viewModel { ProductViewModel(NetworkProductRepository(productApi)) }

// In the list row:
ListItem(
    headlineContent = { Text(product.title) },
    supportingContent = { Text("$${product.price}") },
)
TemplateAPI versionWhat changes
NightProductFields
SleepRepositoryProductRepositoryName only
FakeSleepRepositoryNetworkProductRepositoryNew code, above
SleepUiState, SleepViewModelProductUiState, ProductViewModelNames only
SleepScreenProductScreenRow content and the viewModel default
SleepViewModelTestSame test with a fake ProductRepositoryNames only

Keep a fake that implements ProductRepository. The test runs on it with no network, and it is how you demo ERROR and EMPTY on demand.

TA04Networking traps

Three of these crash the app instead of showing Retry, because the failure is not an IOException.

  • Missing INTERNET permission. The request fails with a SecurityException and the app crashes. Add the permission first.
  • Unknown JSON keys. Without ignoreUnknownKeys = true, the first extra field throws a SerializationException and the app crashes.
  • HTTP error codes. Retrofit throws HttpException for a 404 or 500, which is a RuntimeException. Convert it in the repository, as above.
  • Base URL without a trailing slash. Retrofit.Builder throws IllegalArgumentException at startup.
  • Wrapping the call in withContext(Dispatchers.IO). It is harmless and unnecessary. Say: "Retrofit's suspend functions are main-safe, so I don't switch dispatchers here."
  • Cancellation is not undo. Leaving the screen cancels viewModelScope, which cancels the HTTP call. Say: "That is fine for a GET. For a write, the server may already have applied it, so I would send an idempotency key."
  • Plain http:// URLs. Android blocks cleartext traffic by default from API 28. Use https.
  • No network on the emulator. Keep EP02's two-product local sample ready so the feature still runs.
TA05What was checked

The live DummyJSON response was read on 2026-10-11: the top level holds products, total, skip and limit, and each product has an integer id, string title and description, and a decimal price, plus about twenty other fields. Retrofit 3.0.0 is the latest release listed on GitHub, and its release notes give the converter coordinate.

On 2026-10-11, sections 1 and 2 were compiled byte for byte in a real Android project with Kotlin 2.1.20 and its serialization plugin, Retrofit 3.0.0, converter-kotlinx-serialization 3.0.0 (OkHttp 4.12.0) and kotlinx-serialization-json 1.8.1, the newest 1.8.x release, on the same AGP, Gradle and Compose versions as Part 1. The screen's viewModel line and the ListItem row were placed in a ProductScreen copied from the template with the names changed. The build had no compiler warnings. Against live DummyJSON, NetworkProductRepository returned 20 products, and a base URL that returns 404 came back as IOException("HTTP 404") with the HttpException as its cause. On a Pixel 4a emulator the list showed rows such as Essence Mascara Lash Princess at $9.99.

On 2026-10-12 the same project was switched to the TA01 version-catalog setup exactly as written (kotlin = "2.1.20" was already in its catalog), rebuilt, and its unit tests passed; Gradle resolved retrofit 3.0.0, converter-kotlinx-serialization 3.0.0 and kotlinx-serialization-json 1.8.1. That build used Kotlin 2.1.20, so a newer template needs the matching kotlinxSerialization from the list in TA01.