Android · Build from an empty project

Build an offline sleep journal from an empty project

Type a working list first, wire events and Flow next, then replace memory with Room, Hilt, and a durable note queue. Stop at each checkpoint and make the suggested change yourself. The final reference is for comparison after your attempt.

Four sessions of roughly 45 to 75 minutes, then one 30-minute rebuild. These are planning estimates, not measured completion times.

Android Kotlin and Jetpack Compose only. Fake sleep data and a simulated backend. One app module, one ViewModel, no real health-device API. The simulated server receipts live in local Room storage, and the offline toggle resets on process restart.

Practice explaining your choices

Practice explaining each choice while you build. These are example answers for a small Android feature interview, not a confirmed Eight Sleep assessment or a script to memorize word for word.

A 30-minute feature interview

  1. First five minutes: confirm the requirements, create simple models and show sample data. Use an existing working project if setup is already complete.
  2. Next ten minutes: connect a memory repository, ViewModel and lifecycle-aware StateFlow collection. Handle Loading, Ready and Failure with simple Compose if/else checks.
  3. Next ten minutes: finish the requested interaction, such as search, detail, favorite or editing. Run it before expanding scope.
  4. Last five minutes: check the core behavior and an edge case. Run relevant existing checks when available and explain any verification still owed.

Keep the first version small

Choose your approach

Pick one path at WS04 and keep it through WS08. Both use Android ViewModel and unidirectional state, and both produce the same app. Direct callbacks versus a typed intent/reducer pipeline are teaching choices, not mutually exclusive industry definitions.

MVVM with direct ViewModel functions

Choose this for a small feature when named functions make state ownership easiest to explain.

ui/journal/
  JournalRoute.kt
  JournalScreen.kt
  JournalViewModel.kt
  JournalUiState.kt
  JournalEvent.kt (shared screen callback contract)
  JournalPresentation.kt (filter and legacy example reducer)
  PhaseLab.kt (WS13 optional)
model/
  SleepNight.kt, Note.kt, Journal.kt, SampleNights.kt
data/
  JournalRepository.kt, RoomJournalRepository.kt
  MemoryJournalRepository.kt (WS04 only; remove at WS08)
  local/JournalDatabase.kt, JournalDao.kt, JournalEntities.kt
  remote/DemoBackend.kt, JournalDtos.kt
sync/JournalSyncWorker.kt, SyncQueue.kt
di/JournalModule.kt, IoDispatcher.kt
MainActivity.kt, JournalApplication.kt
Screen callback → route adapter → ViewModel function → immutable state update → StateFlow → Screen
ViewModel coroutine → Repository → Room → observed journal data → StateFlow
  1. WS04 uses direct functions with MemoryJournalRepository and viewModel().
  2. WS08 replaces that ViewModel/route with the injected version and hiltViewModel().
  3. Follow onQueryChanged, onNightSelected and onSaveClicked. The MVVM ViewModel does not call reduceJournal or the legacy shared reducer.
  4. Typed events are not forbidden in MVVM. The common screen callback is an adapter boundary, not evidence that every implementation is MVI.

MVI with intents and a pure reducer

Choose this when explicit input types and reviewable transitions clarify a screen with several controls.

ui/journal/
  JournalRoute.kt
  JournalScreen.kt
  JournalViewModel.kt
  JournalUiState.kt
  JournalIntent.kt (includes result types)
  JournalReducer.kt
  JournalEvent.kt (shared screen callback contract)
  JournalPresentation.kt (filter and legacy example reducer)
  PhaseLab.kt (WS13 optional)
model/
  SleepNight.kt, Note.kt, Journal.kt, SampleNights.kt
data/
  JournalRepository.kt, RoomJournalRepository.kt
  MemoryJournalRepository.kt (WS04 only; remove at WS08)
  local/JournalDatabase.kt, JournalDao.kt, JournalEntities.kt
  remote/DemoBackend.kt, JournalDtos.kt
sync/JournalSyncWorker.kt, SyncQueue.kt
di/JournalModule.kt, IoDispatcher.kt
MainActivity.kt, JournalApplication.kt
Screen callback → route adapter → Intent → ViewModel → pure reducer → control state
ViewModel coroutine → Repository → Room → observed journal data
Save completion → typed result → pure reducer → StateFlow → Screen
  1. WS04 uses intents/reducer with MemoryJournalRepository and viewModel().
  2. WS08 keeps the same intent/reducer and replaces the ViewModel/route with injected final versions.
  3. JournalIntent contains user actions; JournalResult represents save completion. The reducer is pure and exhaustive. The ViewModel owns effects and writes restoration inputs.
  4. MVI can use Android ViewModel and does not require a store library. This lightweight example reduces input values/results while repository and operation flows retain their separate ownership.

Shared by both paths

Show step-by-step code for

Switching displays the other implementation; it does not modify your project. Follow one path through its replacement checkpoints. Printing includes both paths for comparison.

Before starting

  1. Use Android Studio with an Empty Activity Kotlin/Compose project, JDK 17, and SDK 35.
  2. Use dev.dchung.sleepjournal as the base package, and create the subpackages shown in each file. The installed application ID is dev.dchung.sleepjournal.workshop. Directory paths and package declarations must agree.
  3. Every code block is a full file replacement unless its note says otherwise. Early replacements deliberately differ from the final reference.
  4. Keep your template gradle wrapper files. The bundled reference reuses the existing practice Gradle 8.13 wrapper.
  5. Use the pinned versions in WS01. They are a reproducible baseline rather than the latest dependency recommendation.
  6. Remove generated template test files so later expected counts refer only to this workshop.

What you will be able to demonstrate

Build one capability at a time

Choose a step
WS01Create an empty project and a reproducible baseline

Start with one app module that runs before you add architecture.

PieceWhy we add itKeep it simple
One app moduleThis feature has one screen owner and no shared use-case logic yet.Keep the generated project; add only dependencies needed for the next checkpoint.
Interviewer follow-up

Why not set up Room and Hilt immediately?

They are part of the complete learning app. For a short feature task, a working memory implementation gives us useful behavior sooner.

Do this

  1. Create an Android Studio Empty Activity project named SleepJournalWorkshop. Choose Kotlin, package dev.dchung.sleepjournal, and minimum SDK 26.
  2. Select JDK 17 for Gradle. Install Android SDK 35. Keep the Android Studio-generated gradlew scripts and wrapper jar.
  3. Replace the listed Gradle files. This is a pinned learning baseline, not a claim about the newest library versions. Kotlin and Compose compiler versions match. Kapt processes both Room and Hilt here.
  4. Delete the template ExampleUnitTest.kt and ExampleInstrumentedTest.kt. Delete the generated Greeting function and preview when you replace MainActivity in WS03.
  5. Keep the template Activity for this step. Change its greeting to Sleep journal workshop and run the app.

settings.gradle.kts

pluginManagement { repositories { google(); mavenCentral(); gradlePluginPortal() } }
dependencyResolutionManagement {
    repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
    repositories { google(); mavenCentral() }
}
rootProject.name = "SleepJournalWorkshop"
include(":app")

Create or fully replace this file. Type the code before comparing with the final reference.

build.gradle.kts

plugins {
    id("com.android.application") version "8.12.0" apply false
    id("org.jetbrains.kotlin.android") version "2.1.20" apply false
    id("org.jetbrains.kotlin.plugin.compose") version "2.1.20" apply false
    id("org.jetbrains.kotlin.kapt") version "2.1.20" apply false
    id("com.google.dagger.hilt.android") version "2.56.2" apply false
}

Create or fully replace this file. Type the code before comparing with the final reference.

app/build.gradle.kts

plugins {
    id("com.android.application")
    id("org.jetbrains.kotlin.android")
    id("org.jetbrains.kotlin.plugin.compose")
    id("org.jetbrains.kotlin.kapt")
    id("com.google.dagger.hilt.android")
}
android {
    namespace = "dev.dchung.sleepjournal"
    compileSdk = 35
    defaultConfig {
        applicationId = "dev.dchung.sleepjournal.workshop"
        minSdk = 26
        targetSdk = 35
        versionCode = 1
        versionName = "1.0"
        testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
    }
    compileOptions {
        sourceCompatibility = JavaVersion.VERSION_17
        targetCompatibility = JavaVersion.VERSION_17
    }
    kotlinOptions { jvmTarget = "17" }
    buildFeatures { compose = true }
}
kapt { correctErrorTypes = true }
dependencies {
    implementation(platform("androidx.compose:compose-bom:2025.04.01"))
    implementation("androidx.compose.ui:ui")
    implementation("androidx.compose.ui:ui-tooling-preview")
    debugImplementation("androidx.compose.ui:ui-tooling")
    implementation("androidx.compose.foundation:foundation")
    implementation("androidx.compose.material3:material3")
    implementation("androidx.activity:activity-compose:1.10.1")
    implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:2.9.0")
    implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.9.0")
    implementation("androidx.lifecycle:lifecycle-runtime-compose:2.9.0")
    implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0")
    implementation("androidx.room:room-runtime:2.7.1")
    implementation("androidx.room:room-ktx:2.7.1")
    kapt("androidx.room:room-compiler:2.7.1")
    implementation("com.google.dagger:hilt-android:2.56.2")
    kapt("com.google.dagger:hilt-compiler:2.56.2")
    implementation("androidx.hilt:hilt-navigation-compose:1.2.0")
    implementation("androidx.work:work-runtime-ktx:2.10.1")
    implementation("androidx.hilt:hilt-work:1.2.0")
    kapt("androidx.hilt:hilt-compiler:1.2.0")
    testImplementation("junit:junit:4.13.2")
    testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.9.0")
    androidTestImplementation(platform("androidx.compose:compose-bom:2025.04.01"))
    androidTestImplementation("androidx.compose.ui:ui-test-junit4")
    androidTestImplementation("androidx.test.ext:junit:1.2.1")
    androidTestImplementation("androidx.test:runner:1.6.2")
    debugImplementation("androidx.compose.ui:ui-test-manifest")
}

Create or fully replace this file. Type the code before comparing with the final reference.

gradle.properties

android.useAndroidX=true
kotlin.code.style=official
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8

Create or fully replace this file. Type the code before comparing with the final reference.

gradle/wrapper/gradle-wrapper.properties

distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

Create or fully replace this file. Type the code before comparing with the final reference.

app/src/main/AndroidManifest.xml

<manifest xmlns:android="http://schemas.android.com/apk/res/android">
    <application android:label="Sleep journal workshop" android:theme="@android:style/Theme.Material.Light.NoActionBar" android:allowBackup="false" android:supportsRtl="true">
        <activity android:name="dev.dchung.sleepjournal.MainActivity" android:exported="true">
            <intent-filter>
                <action android:name="android.intent.action.MAIN" />
                <category android:name="android.intent.category.LAUNCHER" />
            </intent-filter>
        </activity>
    </application>
</manifest>

Initial manifest. Hilt and custom WorkManager initialization arrive in WS08.

Explain it, then change it yourself

A chat lab is shorter for text entry but adds message ordering and transport decisions. A sleep journal gives us a bounded list, detail, filtering, editable notes, and an offline queue. We choose the journal and one app module. No use-case layer is needed for one ViewModel.

Fundamentals: AF01 · AF02
Practice problems: EP01 · EP08

WS02Model the journal and its screen states

Make loading, readable data, and failure explicit before you write UI branches.

PieceWhy we add itKeep it simple
LoadStateA simple enum tells Compose whether to show loading, an error or the journal.Keep the data and error on JournalUiState and use if/else. No separate content class is needed.
OperationA save can fail while loaded data stays visible. Its status should not replace the loaded journal.Leave it out until the screen has a write or refresh whose status is separate from the initial read.
UserInputIt groups query, favorites filter, selected ID and draft because we update and restore those inputs together.Keep this as a plain data class. UserInput holds current values; actions describe what happened.
JournalUiStateOne immutable snapshot gives the screen everything it needs without reading the database itself.Use this data class directly. loadState chooses the branch; journal and errorMessage supply its values.
Interviewer follow-up

Why use both models and UiState?

A night is stored data. Loading, search text and a selected row are screen concerns. Keeping them separate avoids putting screen-specific fields in our data model.

Do this

  1. Create the model and ui/journal packages under app/src/main/java/dev/dchung/sleepjournal.
  2. Create every file below. Domain values live in model; LoadState, Operation, UserInput and screen callbacks live in ui/journal.
  3. JournalPresentation.kt filters domain data using UI input values and retains the small reduce function for existing presentation test examples. Neither ViewModel uses that legacy reducer.
  4. Keep sample dates fixed so checks do not depend on today.

app/src/main/java/dev/dchung/sleepjournal/model/SleepNight.kt

package dev.dchung.sleepjournal.model

// This is stored sleep data, not loading flags or text the user is editing.
// Keep IDs as plain strings here so the first feature stays quick to build.
data class SleepNight(val id: String, val date: String, val minutes: Int, val favorite: Boolean = false)

Create or fully replace this file. Type the code before comparing with the final reference.

app/src/main/java/dev/dchung/sleepjournal/model/Note.kt

package dev.dchung.sleepjournal.model

enum class SyncStatus { Pending, Acknowledged }
// The note ID also identifies its upload. Create it once, then reuse it on retry.
data class Note(val id: String, val nightId: String, val text: String, val status: SyncStatus)

Create or fully replace this file. Type the code before comparing with the final reference.

app/src/main/java/dev/dchung/sleepjournal/model/Journal.kt

package dev.dchung.sleepjournal.model

// Group the data the screen reads. UI-only query and selection live in UiState.
data class Journal(val nights: List<SleepNight>, val notes: List<Note>)

Create or fully replace this file. Type the code before comparing with the final reference.

app/src/main/java/dev/dchung/sleepjournal/model/SampleNights.kt

package dev.dchung.sleepjournal.model

// Fixed samples let us build and check the screen before wiring an API.
val sampleNights = listOf(
    SleepNight("n1", "2026-10-01", 430),
    SleepNight("n2", "2026-10-02", 470),
    SleepNight("n3", "2026-10-03", 390)
)

Create or fully replace this file. Type the code before comparing with the final reference.

app/src/main/java/dev/dchung/sleepjournal/ui/journal/JournalUiState.kt

package dev.dchung.sleepjournal.ui.journal

import dev.dchung.sleepjournal.model.Journal

// One value chooses which branch the screen displays.
enum class LoadState { Loading, Ready, Failure }

// A failed save should leave the already loaded journal visible.
sealed interface Operation {
    data object Idle : Operation
    data object Busy : Operation
    data class Failed(val message: String) : Operation
}

// These are current input values. Events describe what the user just did.
data class UserInput(
    val query: String = "",
    val favoritesOnly: Boolean = false,
    val selected: String? = null,
    val draft: String = ""
)

// Keep load status, data and error together so each read emits a complete state.
// Ready with an empty journal means a successful read with no results.
data class JournalUiState(
    val loadState: LoadState = LoadState.Loading,
    val journal: Journal = Journal(emptyList(), emptyList()),
    val errorMessage: String? = null,
    val userInput: UserInput = UserInput(),
    val operation: Operation = Operation.Idle,
    val offline: Boolean = false
)

Create or fully replace this file. Type the code before comparing with the final reference.

app/src/main/java/dev/dchung/sleepjournal/ui/journal/JournalEvent.kt

package dev.dchung.sleepjournal.ui.journal


// This shared callback lets both teaching paths reuse the same screen.
// It does not make the app MVI. Direct onQueryChange/onSave lambdas are simpler
// when building only one interview path.
sealed interface JournalEvent {
    data class Query(val value: String) : JournalEvent
    data class Favorites(val value: Boolean) : JournalEvent
    data class Select(val id: String?) : JournalEvent
    data class Draft(val value: String) : JournalEvent
    data class Favorite(val id: String) : JournalEvent
    data object SaveNote : JournalEvent
    data object Sync : JournalEvent
    data class Offline(val value: Boolean) : JournalEvent
}

Create or fully replace this file. Type the code before comparing with the final reference.

app/src/main/java/dev/dchung/sleepjournal/ui/journal/JournalPresentation.kt

package dev.dchung.sleepjournal.ui.journal

import dev.dchung.sleepjournal.model.Journal
import dev.dchung.sleepjournal.model.SleepNight

// Local filtering is a plain calculation. No coroutine or extra use-case class
// is needed because the data is already in memory.
fun filteredNights(journal: Journal, userInput: UserInput): List<SleepNight> =
    journal.nights.filter { night ->
        (!userInput.favoritesOnly || night.favorite) &&
            night.date.contains(userInput.query.trim(), ignoreCase = true)
    }
fun reduce(userInput: UserInput, event: JournalEvent): UserInput = when (event) {
    is JournalEvent.Query -> userInput.copy(query = event.value)
    is JournalEvent.Favorites -> userInput.copy(favoritesOnly = event.value)
    is JournalEvent.Select -> userInput.copy(selected = event.id, draft = "")
    is JournalEvent.Draft -> userInput.copy(draft = event.value)
    is JournalEvent.Favorite, JournalEvent.SaveNote, JournalEvent.Sync, is JournalEvent.Offline -> userInput
}

Create or fully replace this file. Type the code before comparing with the final reference.

Explain it, then change it yourself

Models describe stored data; UiState describes the screen right now. LoadState chooses Loading, Ready or Failure with simple Compose if/else branches. JournalUiState also holds journal data and errorMessage. Operation tracks a save or sync without hiding existing data. UserInput holds the current query, filter, selected ID and draft; it is not an action list. Plain string IDs keep setup simple; each note receives one UUID string reused across retries. Repository writes stay in the ViewModel and data layer, outside pure reducers.

Fundamentals: AF01 · AF02 · AF08
Practice problems: EP01 · EP08 · EP09

WS03Render a list before introducing a ViewModel

See the first useful screen and keep it independently renderable.

PieceWhy we add itKeep it simple
Stateless JournalScreenRendering can be checked without creating Room, Hilt or a ViewModel.Use direct onQueryChange and onSave callbacks when building one path. JournalEvent exists here to share the teaching screen.
Stable list keysA row keeps its identity when filtering changes its position.Use the existing string ID; no wrapper type is needed.
Interviewer follow-up

Is a typed event callback automatically MVI?

No. MVVM can use typed callbacks too. Our MVVM route maps them to named functions; our MVI route maps them to intents and explicit reduced transitions.

Do this

  1. Fully replace root MainActivity.kt and create ui/journal/JournalScreen.kt using these static-stage files.
  2. Do not create JournalRoute.kt yet. If it exists from a prior attempt, remove it for WS03. This Activity passes fixed values and empty callbacks, so no ViewModel is referenced.
  3. Run the app. The list sorts exactly as the supplied list for this static stage. Save and search are intentionally unwired until WS04.

app/src/main/java/dev/dchung/sleepjournal/MainActivity.kt

package dev.dchung.sleepjournal

import dev.dchung.sleepjournal.model.Journal
import dev.dchung.sleepjournal.model.sampleNights
import dev.dchung.sleepjournal.ui.journal.LoadState
import dev.dchung.sleepjournal.ui.journal.JournalScreen
import dev.dchung.sleepjournal.ui.journal.JournalUiState

import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        enableEdgeToEdge()
        setContent {
            MaterialTheme(colorScheme = if (isSystemInDarkTheme()) darkColorScheme() else lightColorScheme()) {
                JournalScreen(JournalUiState(loadState = LoadState.Ready, journal = Journal(sampleNights, emptyList())), {}, {})
            }
        }
    }
}

Full checkpoint file. Keep this stage until the tutorial explicitly replaces it.

app/src/main/java/dev/dchung/sleepjournal/ui/journal/JournalScreen.kt

package dev.dchung.sleepjournal.ui.journal

import dev.dchung.sleepjournal.model.*

import android.content.res.Configuration
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.testTagsAsResourceId
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp

// A stateless screen is easy to preview and check with fixed inputs.
// Callbacks request changes; neither Room nor the ViewModel is created here.
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun JournalScreen(state: JournalUiState, onEvent: (JournalEvent) -> Unit,
    onReload: () -> Unit, modifier: Modifier = Modifier) {
    BackHandler(enabled = state.userInput.selected != null) { onEvent(JournalEvent.Select(null)) }
    Scaffold(modifier = modifier.semantics { testTagsAsResourceId = true },
        topBar = { TopAppBar(title = { Text("Sleep journal") }) }) { padding ->
        Column(Modifier.padding(padding).padding(horizontal = 16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
            Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
                Text("Demo offline")
                Switch(state.offline, { onEvent(JournalEvent.Offline(it)) }, Modifier.testTag("demo-offline"))
            }
            Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
                Text(if (state.operation == Operation.Busy) "Working" else if (state.offline) "Offline" else "Online",
                    Modifier.testTag("sync-status"))
                Button({ onEvent(JournalEvent.Sync) }, Modifier.testTag("sync-now"),
                    enabled = state.operation != Operation.Busy) { Text("Sync now") }
            }
            if (state.operation is Operation.Failed) Text(state.operation.message, color = MaterialTheme.colorScheme.error)
            if (state.loadState == LoadState.Loading) {
                CircularProgressIndicator(Modifier.testTag("journal-loading"))
            } else if (state.loadState == LoadState.Failure) {
                Text(state.errorMessage ?: "Cannot read journal")
                Button(onReload, Modifier.testTag("journal-retry")) { Text("Retry") }
            } else {
                val selected = state.journal.nights.firstOrNull { it.id == state.userInput.selected }
                if (state.userInput.selected != null && selected == null) {
                    Text("Night is no longer available")
                    Button({ onEvent(JournalEvent.Select(null)) }, Modifier.testTag("back-detail")) { Text("Back") }
                } else if (selected != null) {
                    Button({ onEvent(JournalEvent.Select(null)) }, Modifier.testTag("back-detail")) { Text("Back") }
                    Text(selected.date, style = MaterialTheme.typography.headlineSmall)
                    Text("${selected.minutes / 60}h ${selected.minutes % 60}m")
                    OutlinedTextField(state.userInput.draft, { onEvent(JournalEvent.Draft(it)) },
                        Modifier.fillMaxWidth().testTag("note-input"), label = { Text("Note") })
                    Button({ onEvent(JournalEvent.SaveNote) }, Modifier.testTag("note-save"),
                        enabled = state.userInput.draft.trim().isNotEmpty() && state.operation != Operation.Busy) { Text("Save note") }
                    LazyColumn(Modifier.weight(1f).testTag("note-list")) {
                        items(state.journal.notes.filter { it.nightId == selected.id }, key = { it.id }) { note ->
                            Text("${note.text} · ${note.status}", Modifier.padding(vertical = 8.dp).testTag("note-${note.id}"))
                        }
                    }
                } else {
                    OutlinedTextField(state.userInput.query, { onEvent(JournalEvent.Query(it)) },
                        Modifier.fillMaxWidth().testTag("sleep-query"), label = { Text("Search date") })
                    FilterChip(state.userInput.favoritesOnly, { onEvent(JournalEvent.Favorites(!state.userInput.favoritesOnly)) },
                        { Text("Favorites only") }, Modifier.testTag("favorites-filter"))
                    val nights = filteredNights(state.journal, state.userInput)
                    if (nights.isEmpty()) Text("No nights match", Modifier.testTag("journal-empty"))
                    LazyColumn(Modifier.weight(1f).testTag("night-list")) {
                        // Stable IDs keep a row's identity when filtering changes its position.
                        items(nights, key = { it.id }) { night ->
                            ListItem(headlineContent = {
                                TextButton({ onEvent(JournalEvent.Select(night.id)) }, Modifier.testTag("night-${night.id}")) {
                                    Text(night.date)
                                }
                            }, supportingContent = { Text("${night.minutes / 60}h ${night.minutes % 60}m") },
                                trailingContent = {
                                    TextButton({ onEvent(JournalEvent.Favorite(night.id)) }, Modifier.testTag("favorite-${night.id}"),
                                        enabled = state.operation != Operation.Busy) { Text(if (night.favorite) "Unsave" else "Save") }
                                })
                        }
                    }
                }
            }
        }
    }
}
@Preview
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES, fontScale = 2f)
@Composable
private fun JournalPreview() { MaterialTheme { JournalScreen(JournalUiState(loadState = LoadState.Ready, journal = Journal(sampleNights, emptyList())), {}, {}) } }
@Preview
@Composable
private fun LoadingPreview() { MaterialTheme { JournalScreen(JournalUiState(), {}, {}) } }
@Preview
@Composable
private fun FailurePreview() { MaterialTheme { JournalScreen(JournalUiState(loadState = LoadState.Failure, errorMessage = "Cannot read journal"), {}, {}) } }
@Preview
@Composable
private fun EmptyPreview() { MaterialTheme { JournalScreen(JournalUiState(loadState = LoadState.Ready), {}, {}) } }

Stateless screen. JournalRoute is a separate file introduced in WS04.

Explain it, then change it yourself

The screen accepts values and callbacks. This split lets us test UI behavior without database or network setup. Stable LazyColumn keys keep row identity when filtering.

Fundamentals: AF02 · AF17 · AF18
Practice problems: EP02 · EP03 · EP08

WS04Choose MVVM or MVI and build retained in-memory state

Make search, favorites, details, and notes work in memory.

PieceWhy we add itKeep it simple
JournalRepository interfaceThe same ViewModel contract will use memory now and Room later.Use a concrete repository if there is only one source. An interface is not required for every class.
ViewModel and SavedStateHandleThe ViewModel retains inputs through rotation; SavedStateHandle restores small inputs after eligible recreation.Keep durable data in storage. Do not claim saved state is a database or guarantees force-stop recovery.
MVVM or MVIBoth keep state ownership clear. MVI additionally names inputs and reduces input transitions.Choose one path. Named ViewModel functions need no intent or reducer layer.
Interviewer follow-up

Why split Route from Screen?

The route wires the ViewModel and lifecycle-aware collection. The screen gets plain values and callbacks, so rendering does not depend on Android storage or injection.

Do this

  1. Choose MVVM or MVI in the architecture selector. Follow the same choice at WS08; every other data and UI lesson is shared.
  2. Create data/JournalRepository.kt and data/MemoryJournalRepository.kt below. Replace MainActivity.kt with the initial route Activity.
  3. Apply only your selected path files. Both paths use lifecycle-aware collection and the same stateless screen inputs.
  4. Run the whole in-memory feature before installing Room or Hilt. The final reference constructor does not work at this initial checkpoint.

app/src/main/java/dev/dchung/sleepjournal/data/JournalRepository.kt

package dev.dchung.sleepjournal.data

import dev.dchung.sleepjournal.model.Journal
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.StateFlow

// This interface lets the same ViewModel use memory first and Room later.
// It defines the operations both memory and Room implementations provide.
// If the interview has only one data source, a concrete repository is enough.
interface JournalRepository {
    fun observe(): Flow<Journal>
    val offline: StateFlow<Boolean>
    suspend fun seed()
    suspend fun toggle(nightId: String)
    suspend fun addNote(nightId: String, text: String)
    suspend fun sync()
    fun setOffline(value: Boolean)
}

Create or fully replace this file. Type the code before comparing with the final reference.

app/src/main/java/dev/dchung/sleepjournal/data/MemoryJournalRepository.kt

package dev.dchung.sleepjournal.data

import dev.dchung.sleepjournal.model.*
import java.util.UUID
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.*

// Start here to finish a working feature before adding persistence.
// This data survives rotation with its ViewModel, but not process restart.
class MemoryJournalRepository : JournalRepository {
    private val journal = MutableStateFlow(Journal(sampleNights, emptyList()))
    private val disconnected = MutableStateFlow(false)
    override val offline: StateFlow<Boolean> = disconnected.asStateFlow()
    override fun observe(): Flow<Journal> = journal.asStateFlow()
    override suspend fun seed() = Unit
    override suspend fun toggle(nightId: String) {
        journal.update { it.copy(nights = it.nights.map { night ->
            if (night.id == nightId) night.copy(favorite = !night.favorite) else night
        }) }
    }
    override suspend fun addNote(nightId: String, text: String) {
        val clean = text.trim()
        require(clean.isNotEmpty() && clean.length <= 280) { "Write a note of 1 to 280 characters" }
        val note = Note(UUID.randomUUID().toString(), nightId, clean, SyncStatus.Pending)
        journal.update { it.copy(notes = it.notes + note) }
    }
    override suspend fun sync() {
        delay(600)
        check(!offline.value) { "Demo backend is offline" }
        journal.update { it.copy(notes = it.notes.map { note -> note.copy(status = SyncStatus.Acknowledged) }) }
    }
    override fun setOffline(value: Boolean) { disconnected.value = value }
}

Full checkpoint file. Keep this stage until the tutorial explicitly replaces it.

app/src/main/java/dev/dchung/sleepjournal/MainActivity.kt

package dev.dchung.sleepjournal

import dev.dchung.sleepjournal.ui.journal.JournalRoute

import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        enableEdgeToEdge()
        setContent {
            MaterialTheme(colorScheme = if (isSystemInDarkTheme()) darkColorScheme() else lightColorScheme()) {
                JournalRoute()
            }
        }
    }
}

Full checkpoint file. Keep this stage until the tutorial explicitly replaces it.

MVVM in-memory checkpoint

  1. Choose one architecture path and fully replace only the files shown for that path. Do not compile both JournalViewModel classes in one package.
  2. Keep the SavedStateHandle-only constructor and viewModel() at WS04. The repository is MemoryJournalRepository. Hilt arrives at WS08.
  3. Follow the route adapter from a JournalEvent to a named ViewModel function. Query, filter, selection and draft use direct immutable UserInput copies.
  4. Do not use the shared reduce function in this ViewModel. It stays in ui/journal/JournalPresentation.kt for the existing presentation test examples.

app/src/main/java/dev/dchung/sleepjournal/ui/journal/JournalRoute.kt

package dev.dchung.sleepjournal.ui.journal

import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel

// The route owns Android-specific wiring; the screen only renders values.
// Lifecycle-aware collection pauses when this UI is not active.
@Composable
fun JournalRoute(modifier: Modifier = Modifier, viewModel: JournalViewModel = viewModel()) {
    val state by viewModel.uiState.collectAsStateWithLifecycle()
    // Map the common teaching callback to this path's ViewModel functions or intents.
    JournalScreen(state = state, onEvent = { event ->
        when (event) {
            is JournalEvent.Query -> viewModel.onQueryChanged(event.value)
            is JournalEvent.Favorites -> viewModel.onFavoritesChanged(event.value)
            is JournalEvent.Select -> viewModel.onNightSelected(event.id)
            is JournalEvent.Draft -> viewModel.onDraftChanged(event.value)
            is JournalEvent.Favorite -> viewModel.onFavoriteClicked(event.id)
            JournalEvent.SaveNote -> viewModel.onSaveClicked()
            JournalEvent.Sync -> viewModel.onSyncClicked()
            is JournalEvent.Offline -> viewModel.onOfflineChanged(event.value)
        }
    }, onReload = viewModel::reload, modifier = modifier)
}

Selected architecture checkpoint. Replace route and ViewModel together; never install both variants.

app/src/main/java/dev/dchung/sleepjournal/ui/journal/JournalScreen.kt

package dev.dchung.sleepjournal.ui.journal

import dev.dchung.sleepjournal.model.*

import android.content.res.Configuration
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.testTagsAsResourceId
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp

// A stateless screen is easy to preview and check with fixed inputs.
// Callbacks request changes; neither Room nor the ViewModel is created here.
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun JournalScreen(state: JournalUiState, onEvent: (JournalEvent) -> Unit,
    onReload: () -> Unit, modifier: Modifier = Modifier) {
    BackHandler(enabled = state.userInput.selected != null) { onEvent(JournalEvent.Select(null)) }
    Scaffold(modifier = modifier.semantics { testTagsAsResourceId = true },
        topBar = { TopAppBar(title = { Text("Sleep journal") }) }) { padding ->
        Column(Modifier.padding(padding).padding(horizontal = 16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
            Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
                Text("Demo offline")
                Switch(state.offline, { onEvent(JournalEvent.Offline(it)) }, Modifier.testTag("demo-offline"))
            }
            Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
                Text(if (state.operation == Operation.Busy) "Working" else if (state.offline) "Offline" else "Online",
                    Modifier.testTag("sync-status"))
                Button({ onEvent(JournalEvent.Sync) }, Modifier.testTag("sync-now"),
                    enabled = state.operation != Operation.Busy) { Text("Sync now") }
            }
            if (state.operation is Operation.Failed) Text(state.operation.message, color = MaterialTheme.colorScheme.error)
            if (state.loadState == LoadState.Loading) {
                CircularProgressIndicator(Modifier.testTag("journal-loading"))
            } else if (state.loadState == LoadState.Failure) {
                Text(state.errorMessage ?: "Cannot read journal")
                Button(onReload, Modifier.testTag("journal-retry")) { Text("Retry") }
            } else {
                val selected = state.journal.nights.firstOrNull { it.id == state.userInput.selected }
                if (state.userInput.selected != null && selected == null) {
                    Text("Night is no longer available")
                    Button({ onEvent(JournalEvent.Select(null)) }, Modifier.testTag("back-detail")) { Text("Back") }
                } else if (selected != null) {
                    Button({ onEvent(JournalEvent.Select(null)) }, Modifier.testTag("back-detail")) { Text("Back") }
                    Text(selected.date, style = MaterialTheme.typography.headlineSmall)
                    Text("${selected.minutes / 60}h ${selected.minutes % 60}m")
                    OutlinedTextField(state.userInput.draft, { onEvent(JournalEvent.Draft(it)) },
                        Modifier.fillMaxWidth().testTag("note-input"), label = { Text("Note") })
                    Button({ onEvent(JournalEvent.SaveNote) }, Modifier.testTag("note-save"),
                        enabled = state.userInput.draft.trim().isNotEmpty() && state.operation != Operation.Busy) { Text("Save note") }
                    LazyColumn(Modifier.weight(1f).testTag("note-list")) {
                        items(state.journal.notes.filter { it.nightId == selected.id }, key = { it.id }) { note ->
                            Text("${note.text} · ${note.status}", Modifier.padding(vertical = 8.dp).testTag("note-${note.id}"))
                        }
                    }
                } else {
                    OutlinedTextField(state.userInput.query, { onEvent(JournalEvent.Query(it)) },
                        Modifier.fillMaxWidth().testTag("sleep-query"), label = { Text("Search date") })
                    FilterChip(state.userInput.favoritesOnly, { onEvent(JournalEvent.Favorites(!state.userInput.favoritesOnly)) },
                        { Text("Favorites only") }, Modifier.testTag("favorites-filter"))
                    val nights = filteredNights(state.journal, state.userInput)
                    if (nights.isEmpty()) Text("No nights match", Modifier.testTag("journal-empty"))
                    LazyColumn(Modifier.weight(1f).testTag("night-list")) {
                        // Stable IDs keep a row's identity when filtering changes its position.
                        items(nights, key = { it.id }) { night ->
                            ListItem(headlineContent = {
                                TextButton({ onEvent(JournalEvent.Select(night.id)) }, Modifier.testTag("night-${night.id}")) {
                                    Text(night.date)
                                }
                            }, supportingContent = { Text("${night.minutes / 60}h ${night.minutes % 60}m") },
                                trailingContent = {
                                    TextButton({ onEvent(JournalEvent.Favorite(night.id)) }, Modifier.testTag("favorite-${night.id}"),
                                        enabled = state.operation != Operation.Busy) { Text(if (night.favorite) "Unsave" else "Save") }
                                })
                        }
                    }
                }
            }
        }
    }
}
@Preview
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES, fontScale = 2f)
@Composable
private fun JournalPreview() { MaterialTheme { JournalScreen(JournalUiState(loadState = LoadState.Ready, journal = Journal(sampleNights, emptyList())), {}, {}) } }
@Preview
@Composable
private fun LoadingPreview() { MaterialTheme { JournalScreen(JournalUiState(), {}, {}) } }
@Preview
@Composable
private fun FailurePreview() { MaterialTheme { JournalScreen(JournalUiState(loadState = LoadState.Failure, errorMessage = "Cannot read journal"), {}, {}) } }
@Preview
@Composable
private fun EmptyPreview() { MaterialTheme { JournalScreen(JournalUiState(loadState = LoadState.Ready), {}, {}) } }

Selected architecture checkpoint. Replace route and ViewModel together; never install both variants.

app/src/main/java/dev/dchung/sleepjournal/ui/journal/JournalViewModel.kt

package dev.dchung.sleepjournal.ui.journal

import dev.dchung.sleepjournal.model.*
import dev.dchung.sleepjournal.data.JournalRepository
import dev.dchung.sleepjournal.data.MemoryJournalRepository

import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch

@OptIn(ExperimentalCoroutinesApi::class)
class JournalViewModel(private val saved: SavedStateHandle) : ViewModel() {
    private val repository: JournalRepository = MemoryJournalRepository()
    // The ViewModel retains screen inputs through rotation. SavedStateHandle
    // restores small inputs after eligible recreation; it is not a database.
    private val userInput = MutableStateFlow(UserInput(
        query = saved["query"] ?: "", favoritesOnly = saved["favorites"] ?: false,
        selected = saved.get<String>("selected"), draft = saved["draft"] ?: ""))
    private val operation = MutableStateFlow<Operation>(Operation.Idle)
    private val retry = MutableStateFlow(0)
    // Retry starts a fresh read subscription after a terminal read failure.
    // Rethrow cancellation so leaving the owner can stop upstream work.
    private val readFlow: Flow<JournalUiState> = retry.flatMapLatest {
        repository.observe().map { JournalUiState(loadState = LoadState.Ready, journal = it) }
            .onStart { emit(JournalUiState()) }
            .catch { failure ->
                if (failure is CancellationException) throw failure
                emit(JournalUiState(loadState = LoadState.Failure,
                    errorMessage = failure.message ?: "Cannot read journal"))
            }
    }
    // Combine the latest data and inputs into one screen snapshot.
    // stateIn exposes a hot StateFlow with a current value; this upstream shares
    // one subscription while observed and stops after five seconds without collectors.
    val uiState: StateFlow<JournalUiState> = combine(readFlow, userInput, operation, repository.offline) { readState, input, op, offline ->
        readState.copy(userInput = input, operation = op, offline = offline)
    }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), JournalUiState())
    init { runOperation { repository.seed() } }
    fun reload() { retry.update { it + 1 } }
    // MVVM uses named functions and copy to update immutable inputs directly.
    fun onQueryChanged(value: String) = updateUserInput(userInput.value.copy(query = value))
    fun onFavoritesChanged(value: Boolean) = updateUserInput(userInput.value.copy(favoritesOnly = value))
    fun onNightSelected(id: String?) = updateUserInput(userInput.value.copy(selected = id, draft = ""))
    fun onDraftChanged(value: String) = updateUserInput(userInput.value.copy(draft = value))
    fun onFavoriteClicked(id: String) = runOperation { repository.toggle(id) }
    fun onOfflineChanged(value: Boolean) = repository.setOffline(value)
    fun onSyncClicked() = runOperation { repository.sync() }
    fun onSaveClicked() {
        val form = userInput.value
        val id = form.selected ?: return
        runOperation {
            repository.addNote(id, form.draft)
            // Do not clear a newer draft typed while this save was running.
            if (userInput.value.selected == id && userInput.value.draft == form.draft) {
                updateUserInput(userInput.value.copy(draft = ""))
            }
        }
    }
    private fun updateUserInput(value: UserInput) {
        userInput.value = value
        saved["query"] = value.query
        saved["favorites"] = value.favoritesOnly
        saved["selected"] = value.selected
        saved["draft"] = value.draft
    }
    private fun runOperation(block: suspend () -> Unit) {
        if (operation.value == Operation.Busy) return
        operation.value = Operation.Busy
        // The ViewModel owns this work; clearing it cancels the coroutine.
        // This stage uses memory; Room and a worker are later extensions.
        viewModelScope.launch {
            try { block(); operation.value = Operation.Idle }
            catch (cancelled: CancellationException) { operation.value = Operation.Idle; throw cancelled }
            catch (failure: Exception) { operation.value = Operation.Failed(failure.message ?: "Operation failed") }
        }
    }
}

Selected architecture checkpoint. Replace route and ViewModel together; never install both variants.

This MVVM example exposes named ViewModel functions and updates immutable state directly. The screen uses a shared event callback for reusable rendering and tests; the route maps that UI contract to direct calls. Typed callbacks are compatible with MVVM, so event syntax alone does not identify MVI.

MVI in-memory checkpoint

  1. Choose one architecture path and fully replace only the files shown for that path. Do not compile both JournalViewModel classes in one package.
  2. Keep the SavedStateHandle-only constructor and viewModel() at WS04. The repository is MemoryJournalRepository. Hilt arrives at WS08.
  3. Follow the route adapter from a JournalEvent to JournalIntent. onIntent first applies the pure reducer, then owns any coroutine or repository work.
  4. Keep JournalIntent.kt and JournalReducer.kt beside the ViewModel. JournalResult.NoteSaved re-enters the pure reducer after a save; repository journal data and operation state still come from their existing flows.

app/src/main/java/dev/dchung/sleepjournal/ui/journal/JournalIntent.kt

package dev.dchung.sleepjournal.ui.journal

import dev.dchung.sleepjournal.model.*

// MVI names every user action so transitions have explicit inputs.
// Use named ViewModel functions instead if they are easier to finish and explain.
sealed interface JournalIntent {
    data class QueryChanged(val value: String) : JournalIntent
    data class FavoritesChanged(val value: Boolean) : JournalIntent
    data class NightSelected(val id: String?) : JournalIntent
    data class DraftChanged(val value: String) : JournalIntent
    data class FavoriteClicked(val id: String) : JournalIntent
    data object SaveClicked : JournalIntent
    data object SyncClicked : JournalIntent
    data class OfflineChanged(val value: Boolean) : JournalIntent
    data object RetryRead : JournalIntent
}
// A save finishes later. Carry the submitted values back so completion
// can avoid clearing a newer draft or a different selected night.
sealed interface JournalResult {
    data class NoteSaved(val nightId: String, val submittedDraft: String) : JournalResult
}

Selected architecture checkpoint. Replace route and ViewModel together; never install both variants.

app/src/main/java/dev/dchung/sleepjournal/ui/journal/JournalReducer.kt

package dev.dchung.sleepjournal.ui.journal

import dev.dchung.sleepjournal.model.*

// A reducer returns the next UserInput value. Same inputs give the same output.
// It does not launch work or write storage; the ViewModel owns those effects.
fun reduceJournal(userInput: UserInput, intent: JournalIntent): UserInput = when (intent) {
    is JournalIntent.QueryChanged -> userInput.copy(query = intent.value)
    is JournalIntent.FavoritesChanged -> userInput.copy(favoritesOnly = intent.value)
    is JournalIntent.NightSelected -> userInput.copy(selected = intent.id, draft = "")
    is JournalIntent.DraftChanged -> userInput.copy(draft = intent.value)
    is JournalIntent.FavoriteClicked, JournalIntent.SaveClicked, JournalIntent.SyncClicked,
    is JournalIntent.OfflineChanged, JournalIntent.RetryRead -> userInput
}
// Only clear the draft that actually finished saving.
// This reducer owns userInput/results, not every JournalUiState transition.
fun reduceJournal(userInput: UserInput, result: JournalResult): UserInput = when (result) {
    is JournalResult.NoteSaved ->
        if (userInput.selected == result.nightId && userInput.draft == result.submittedDraft)
            userInput.copy(draft = "") else userInput
}

Selected architecture checkpoint. Replace route and ViewModel together; never install both variants.

app/src/main/java/dev/dchung/sleepjournal/ui/journal/JournalRoute.kt

package dev.dchung.sleepjournal.ui.journal

import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel

// The route owns Android-specific wiring; the screen only renders values.
// Lifecycle-aware collection pauses when this UI is not active.
@Composable
fun JournalRoute(modifier: Modifier = Modifier, viewModel: JournalViewModel = viewModel()) {
    val state by viewModel.uiState.collectAsStateWithLifecycle()
    // Map the common teaching callback to this path's ViewModel functions or intents.
    JournalScreen(state = state, onEvent = { event ->
        val intent = when (event) {
            is JournalEvent.Query -> JournalIntent.QueryChanged(event.value)
            is JournalEvent.Favorites -> JournalIntent.FavoritesChanged(event.value)
            is JournalEvent.Select -> JournalIntent.NightSelected(event.id)
            is JournalEvent.Draft -> JournalIntent.DraftChanged(event.value)
            is JournalEvent.Favorite -> JournalIntent.FavoriteClicked(event.id)
            JournalEvent.SaveNote -> JournalIntent.SaveClicked
            JournalEvent.Sync -> JournalIntent.SyncClicked
            is JournalEvent.Offline -> JournalIntent.OfflineChanged(event.value)
        }
        viewModel.onIntent(intent)
    }, onReload = { viewModel.onIntent(JournalIntent.RetryRead) }, modifier = modifier)
}

Selected architecture checkpoint. Replace route and ViewModel together; never install both variants.

app/src/main/java/dev/dchung/sleepjournal/ui/journal/JournalScreen.kt

package dev.dchung.sleepjournal.ui.journal

import dev.dchung.sleepjournal.model.*

import android.content.res.Configuration
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.testTagsAsResourceId
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp

// A stateless screen is easy to preview and check with fixed inputs.
// Callbacks request changes; neither Room nor the ViewModel is created here.
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun JournalScreen(state: JournalUiState, onEvent: (JournalEvent) -> Unit,
    onReload: () -> Unit, modifier: Modifier = Modifier) {
    BackHandler(enabled = state.userInput.selected != null) { onEvent(JournalEvent.Select(null)) }
    Scaffold(modifier = modifier.semantics { testTagsAsResourceId = true },
        topBar = { TopAppBar(title = { Text("Sleep journal") }) }) { padding ->
        Column(Modifier.padding(padding).padding(horizontal = 16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
            Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
                Text("Demo offline")
                Switch(state.offline, { onEvent(JournalEvent.Offline(it)) }, Modifier.testTag("demo-offline"))
            }
            Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
                Text(if (state.operation == Operation.Busy) "Working" else if (state.offline) "Offline" else "Online",
                    Modifier.testTag("sync-status"))
                Button({ onEvent(JournalEvent.Sync) }, Modifier.testTag("sync-now"),
                    enabled = state.operation != Operation.Busy) { Text("Sync now") }
            }
            if (state.operation is Operation.Failed) Text(state.operation.message, color = MaterialTheme.colorScheme.error)
            if (state.loadState == LoadState.Loading) {
                CircularProgressIndicator(Modifier.testTag("journal-loading"))
            } else if (state.loadState == LoadState.Failure) {
                Text(state.errorMessage ?: "Cannot read journal")
                Button(onReload, Modifier.testTag("journal-retry")) { Text("Retry") }
            } else {
                val selected = state.journal.nights.firstOrNull { it.id == state.userInput.selected }
                if (state.userInput.selected != null && selected == null) {
                    Text("Night is no longer available")
                    Button({ onEvent(JournalEvent.Select(null)) }, Modifier.testTag("back-detail")) { Text("Back") }
                } else if (selected != null) {
                    Button({ onEvent(JournalEvent.Select(null)) }, Modifier.testTag("back-detail")) { Text("Back") }
                    Text(selected.date, style = MaterialTheme.typography.headlineSmall)
                    Text("${selected.minutes / 60}h ${selected.minutes % 60}m")
                    OutlinedTextField(state.userInput.draft, { onEvent(JournalEvent.Draft(it)) },
                        Modifier.fillMaxWidth().testTag("note-input"), label = { Text("Note") })
                    Button({ onEvent(JournalEvent.SaveNote) }, Modifier.testTag("note-save"),
                        enabled = state.userInput.draft.trim().isNotEmpty() && state.operation != Operation.Busy) { Text("Save note") }
                    LazyColumn(Modifier.weight(1f).testTag("note-list")) {
                        items(state.journal.notes.filter { it.nightId == selected.id }, key = { it.id }) { note ->
                            Text("${note.text} · ${note.status}", Modifier.padding(vertical = 8.dp).testTag("note-${note.id}"))
                        }
                    }
                } else {
                    OutlinedTextField(state.userInput.query, { onEvent(JournalEvent.Query(it)) },
                        Modifier.fillMaxWidth().testTag("sleep-query"), label = { Text("Search date") })
                    FilterChip(state.userInput.favoritesOnly, { onEvent(JournalEvent.Favorites(!state.userInput.favoritesOnly)) },
                        { Text("Favorites only") }, Modifier.testTag("favorites-filter"))
                    val nights = filteredNights(state.journal, state.userInput)
                    if (nights.isEmpty()) Text("No nights match", Modifier.testTag("journal-empty"))
                    LazyColumn(Modifier.weight(1f).testTag("night-list")) {
                        // Stable IDs keep a row's identity when filtering changes its position.
                        items(nights, key = { it.id }) { night ->
                            ListItem(headlineContent = {
                                TextButton({ onEvent(JournalEvent.Select(night.id)) }, Modifier.testTag("night-${night.id}")) {
                                    Text(night.date)
                                }
                            }, supportingContent = { Text("${night.minutes / 60}h ${night.minutes % 60}m") },
                                trailingContent = {
                                    TextButton({ onEvent(JournalEvent.Favorite(night.id)) }, Modifier.testTag("favorite-${night.id}"),
                                        enabled = state.operation != Operation.Busy) { Text(if (night.favorite) "Unsave" else "Save") }
                                })
                        }
                    }
                }
            }
        }
    }
}
@Preview
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES, fontScale = 2f)
@Composable
private fun JournalPreview() { MaterialTheme { JournalScreen(JournalUiState(loadState = LoadState.Ready, journal = Journal(sampleNights, emptyList())), {}, {}) } }
@Preview
@Composable
private fun LoadingPreview() { MaterialTheme { JournalScreen(JournalUiState(), {}, {}) } }
@Preview
@Composable
private fun FailurePreview() { MaterialTheme { JournalScreen(JournalUiState(loadState = LoadState.Failure, errorMessage = "Cannot read journal"), {}, {}) } }
@Preview
@Composable
private fun EmptyPreview() { MaterialTheme { JournalScreen(JournalUiState(loadState = LoadState.Ready), {}, {}) } }

Selected architecture checkpoint. Replace route and ViewModel together; never install both variants.

app/src/main/java/dev/dchung/sleepjournal/ui/journal/JournalViewModel.kt

package dev.dchung.sleepjournal.ui.journal

import dev.dchung.sleepjournal.model.*
import dev.dchung.sleepjournal.data.JournalRepository
import dev.dchung.sleepjournal.data.MemoryJournalRepository

import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch

@OptIn(ExperimentalCoroutinesApi::class)
class JournalViewModel(private val saved: SavedStateHandle) : ViewModel() {
    private val repository: JournalRepository = MemoryJournalRepository()
    // The ViewModel retains screen inputs through rotation. SavedStateHandle
    // restores small inputs after eligible recreation; it is not a database.
    private val userInput = MutableStateFlow(UserInput(
        query = saved["query"] ?: "", favoritesOnly = saved["favorites"] ?: false,
        selected = saved.get<String>("selected"), draft = saved["draft"] ?: ""))
    private val operation = MutableStateFlow<Operation>(Operation.Idle)
    private val retry = MutableStateFlow(0)
    // Retry starts a fresh read subscription after a terminal read failure.
    // Rethrow cancellation so leaving the owner can stop upstream work.
    private val readFlow: Flow<JournalUiState> = retry.flatMapLatest {
        repository.observe().map { JournalUiState(loadState = LoadState.Ready, journal = it) }
            .onStart { emit(JournalUiState()) }
            .catch { failure ->
                if (failure is CancellationException) throw failure
                emit(JournalUiState(loadState = LoadState.Failure,
                    errorMessage = failure.message ?: "Cannot read journal"))
            }
    }
    // Combine the latest data and inputs into one screen snapshot.
    // stateIn exposes a hot StateFlow with a current value; this upstream shares
    // one subscription while observed and stops after five seconds without collectors.
    val uiState: StateFlow<JournalUiState> = combine(readFlow, userInput, operation, repository.offline) { readState, input, op, offline ->
        readState.copy(userInput = input, operation = op, offline = offline)
    }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), JournalUiState())
    init { runOperation { repository.seed() } }
    // The reducer changes input state; this ViewModel performs save/sync effects.
    fun onIntent(intent: JournalIntent) {
        updateUserInput(reduceJournal(userInput.value, intent))
        when (intent) {
            is JournalIntent.FavoriteClicked -> runOperation { repository.toggle(intent.id) }
            JournalIntent.SaveClicked -> {
                val form = userInput.value
                val id = form.selected ?: return
                runOperation {
                    repository.addNote(id, form.draft)
                    updateUserInput(reduceJournal(userInput.value, JournalResult.NoteSaved(id, form.draft)))
                }
            }
            JournalIntent.SyncClicked -> runOperation { repository.sync() }
            is JournalIntent.OfflineChanged -> repository.setOffline(intent.value)
            JournalIntent.RetryRead -> retry.update { it + 1 }
            is JournalIntent.QueryChanged, is JournalIntent.FavoritesChanged,
            is JournalIntent.NightSelected, is JournalIntent.DraftChanged -> Unit
        }
    }
    private fun updateUserInput(value: UserInput) {
        userInput.value = value
        saved["query"] = value.query
        saved["favorites"] = value.favoritesOnly
        saved["selected"] = value.selected
        saved["draft"] = value.draft
    }
    private fun runOperation(block: suspend () -> Unit) {
        if (operation.value == Operation.Busy) return
        operation.value = Operation.Busy
        // The ViewModel owns this work; clearing it cancels the coroutine.
        // This stage uses memory; Room and a worker are later extensions.
        viewModelScope.launch {
            try { block(); operation.value = Operation.Idle }
            catch (cancelled: CancellationException) { operation.value = Operation.Idle; throw cancelled }
            catch (failure: Exception) { operation.value = Operation.Failed(failure.message ?: "Operation failed") }
        }
    }
}

Selected architecture checkpoint. Replace route and ViewModel together; never install both variants.

This MVI example adds explicit typed intents, pure input/result transitions, and ViewModel-owned effects. A reducer cannot call Room, launch a coroutine, or write SavedStateHandle. MVVM and MVI overlap: Android ViewModel is the lifecycle owner in both, and neither approach requires a new framework.

Explain it, then change it yourself

Both paths use the same layers, source of truth, StateFlow, SavedStateHandle and UI. Choose direct named ViewModel functions (MVVM) or typed intent dispatch plus a pure reducer (MVI). SavedStateHandle restores small inputs after eligible system recreation; it is not durable storage and does not guarantee force-stop or task-dismissal recovery.

Fundamentals: AF02 · AF05 · AF07 · AF09
Practice problems: EP01 · EP04 · EP05 · EP08

WS05Prove cold collection and hot sharing with virtual time

Observe producer ownership instead of memorizing definitions.

PieceWhy we add itKeep it simple
WhileSubscribed(5_000)It stops upstream collection five seconds after the last subscriber leaves and avoids restarting immediately during short gaps.Keep one StateFlow for the screen. The timeout is a choice, not a universal required value.
Interviewer follow-up

Does WhileSubscribed delete the state after five seconds?

No. It stops upstream work when there are no subscribers. With this configuration, the StateFlow keeps its most recent value.

Do this

  1. Create FlowOwnershipTest.kt under app/src/test/java/dev/dchung/sleepjournal/ui/journal. Its package is dev.dchung.sleepjournal.ui.journal.
  2. Run the coldCollectorsStartSeparateProducers test alone, then stateInSharesThenStopsItsUpstreamAfterTimeout.
  3. Read JournalViewModel.uiState and compare its WhileSubscribed timeout with the test.

app/src/test/java/dev/dchung/sleepjournal/ui/journal/FlowOwnershipTest.kt

package dev.dchung.sleepjournal.ui.journal

import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.test.*
import org.junit.Assert.*
import org.junit.Test

@OptIn(ExperimentalCoroutinesApi::class)
class FlowOwnershipTest {
    @Test fun coldCollectorsStartSeparateProducers() = runTest {
        var starts = 0
        val cold = flow { starts++; emit("night") }
        cold.first(); cold.first()
        assertEquals(2, starts)
    }
    @Test fun stateInSharesThenStopsItsUpstreamAfterTimeout() = runTest {
        var starts = 0
        var stops = 0
        val cold = flow {
            starts++
            try { emit(7); awaitCancellation() } finally { stops++ }
        }
        val hot = cold.stateIn(backgroundScope, SharingStarted.WhileSubscribed(5_000), 0)
        val first = backgroundScope.launch { hot.collect {} }
        val second = backgroundScope.launch { hot.collect {} }
        runCurrent()
        assertEquals(1, starts)
        assertEquals(7, hot.value)
        first.cancel(); second.cancel(); runCurrent()
        advanceTimeBy(4_999); runCurrent()
        assertEquals(0, stops)
        advanceTimeBy(1); runCurrent()
        assertEquals(1, stops)
        assertEquals(7, hot.value)
    }
    @Test fun flatMapLatestCancelsOldSearchBeforeItCanPublish() = runTest {
        val query = MutableSharedFlow<String>()
        val results = mutableListOf<String>()
        val job = backgroundScope.launch {
            query.flatMapLatest { term -> flow { delay(if (term == "old") 1_000 else 100); emit(term) } }
                .collect { results += it }
        }
        runCurrent()
        query.emit("old")
        advanceTimeBy(10); runCurrent()
        query.emit("new")
        advanceTimeBy(2_000); runCurrent()
        assertEquals(listOf("new"), results)
        job.cancel()
    }
}

Create or fully replace this file. Type the code before comparing with the final reference.

Explain it, then change it yourself

Flow is an interface and does not promise coldness. flow { } is cold; MutableStateFlow is hot. stateIn shares an upstream subscription in its supplied scope. WhileSubscribed stops upstream work, but the StateFlow object and its last value still exist.

Fundamentals: AF03 · AF05 · AF07
Practice problems: EP01 · EP02 · EP08

WS06Cancel old search work and choose operators deliberately

Keep an older response from replacing the current query.

PieceWhy we add itKeep it simple
flatMapLatest for remote searchA newer query replaces the older inner producer so a cooperating stale request cannot publish.Do not add debounce or asynchronous search machinery to this small local list.
Interviewer follow-up

Would you use collectLatest to save notes?

Not for independent saves that must all persist. Latest operators cancel previous work; that fits replaceable searches, not an append-only queue.

Do this

  1. Run flatMapLatestCancelsOldSearchBeforeItCanPublish in the same test file.
  2. Change flatMapLatest to flatMapConcat. Run the test and inspect its failure, then restore flatMapLatest.
  3. For the journal, read filteredNights. It computes local results synchronously from the latest input values and journal, so there is no asynchronous query response race.
  4. For a network search adaptation, place debounce and distinctUntilChanged before flatMapLatest. Keep the request query in the result model and cancel cooperating requests.

Explain it, then change it yourself

flatMapLatest cancels an inner producer. collectLatest cancels processing of a previous emission. buffer overlaps producer and consumer; conflate may discard intermediate values. StateFlow already conflates equal values. Search can drop stale results; a durable outbox cannot drop independent operations.

Fundamentals: AF04 · AF06
Practice problems: EP02 · EP04 · EP11

WS07Describe the durable data in Room

Create the schema and observable queries before replacing the in-memory implementation.

PieceWhy we add itKeep it simple
Room DAO interfaceRoom generates an implementation of an annotated DAO contract, which can be an interface or abstract class. An interface is the simple choice here.The memory checkpoint does not need a database or DAO.
Pending note rowsThe database holds the durable retry queue, including each note ID.If synchronization is not requested, persist notes without building an outbox.
Interviewer follow-up

Why keep entities out of the screen?

Table details can change independently of screen data. Mapping in the repository keeps database annotations and columns in the data layer.

Do this

  1. Create all three data/local files below. They compile beside the in-memory app and do not change behavior yet.
  2. Find each primary key. Night IDs identify entities; note IDs identify append operations.
  3. Read the favorite UPDATE and the pending query. WorkManager will schedule attempts, while this table holds the actual queue.

app/src/main/java/dev/dchung/sleepjournal/data/local/JournalEntities.kt

package dev.dchung.sleepjournal.data.local

import androidx.room.*
import dev.dchung.sleepjournal.model.*

@Entity(tableName = "nights")
// Room annotations and table shapes stay in data/local.
// Mappers below turn them into the simple models returned to the screen.
data class NightEntity(@PrimaryKey val id: String, val date: String, val minutes: Int, val favorite: Boolean)
@Entity(tableName = "notes", foreignKeys = [ForeignKey(entity = NightEntity::class,
    parentColumns = ["id"], childColumns = ["nightId"])], indices = [Index("nightId")])
data class NoteEntity(@PrimaryKey val id: String, val nightId: String, val text: String, val acknowledged: Boolean)
@Entity(tableName = "demo_receipts")
data class DemoReceipt(@PrimaryKey val operationId: String, val nightId: String, val text: String)
fun NightEntity.toDomain() = SleepNight(id, date, minutes, favorite)
fun NoteEntity.toDomain() = Note(id, nightId, text,
    if (acknowledged) SyncStatus.Acknowledged else SyncStatus.Pending)

Create or fully replace this file. Type the code before comparing with the final reference.

app/src/main/java/dev/dchung/sleepjournal/data/local/JournalDao.kt

package dev.dchung.sleepjournal.data.local

import androidx.room.*
import kotlinx.coroutines.flow.Flow

// Room generates an implementation of this annotated DAO contract.
// An interface is simple here; Room also supports an abstract class.
@Dao
interface JournalDao {
    @Query("SELECT * FROM nights ORDER BY date DESC, id") fun nights(): Flow<List<NightEntity>>
    @Query("SELECT * FROM notes ORDER BY id") fun notes(): Flow<List<NoteEntity>>
    @Insert(onConflict = OnConflictStrategy.IGNORE) suspend fun seed(nights: List<NightEntity>)
    @Query("UPDATE nights SET favorite = NOT favorite WHERE id = :id") suspend fun toggle(id: String)
    @Insert(onConflict = OnConflictStrategy.IGNORE) suspend fun insert(note: NoteEntity)
    // Pending rows are the durable outbox. WorkManager schedules attempts;
    // it does not contain the notes themselves.
    @Query("SELECT * FROM notes WHERE acknowledged = 0 ORDER BY id") suspend fun pending(): List<NoteEntity>
    @Query("UPDATE notes SET acknowledged = 1 WHERE id = :id") suspend fun acknowledge(id: String)
    @Insert(onConflict = OnConflictStrategy.IGNORE) suspend fun receipt(receipt: DemoReceipt)
    @Query("SELECT * FROM demo_receipts WHERE operationId = :id") suspend fun receiptById(id: String): DemoReceipt?
}

Create or fully replace this file. Type the code before comparing with the final reference.

app/src/main/java/dev/dchung/sleepjournal/data/local/JournalDatabase.kt

package dev.dchung.sleepjournal.data.local

import androidx.room.Database
import androidx.room.RoomDatabase

// Room stores rows across process death. Schema export is off only for this lab;
// production data needs exported schemas and deliberate migrations.
@Database(entities = [NightEntity::class, NoteEntity::class, DemoReceipt::class], version = 1, exportSchema = false)
abstract class JournalDatabase : RoomDatabase() { abstract fun journalDao(): JournalDao }

Create or fully replace this file. Type the code before comparing with the final reference.

Explain it, then change it yourself

Room Flow queries react to invalidation and can rerun even if a relevant row did not change. The mapper keeps entities inside the data layer. This toy database has exportSchema=false to keep setup short. Production projects export schemas, version migrations, and test upgrades; never ship a destructive migration for valuable user notes.

Fundamentals: AF10
Practice problems: EP01 · EP05 · EP08 · EP11

WS08Replace memory with Room and wire Hilt as one compile checkpoint

Persist notes and favorites without making Compose own storage.

PieceWhy we add itKeep it simple
HiltThe complete app shares database, backend and repository dependencies across ViewModel and worker.Direct construction is enough for the initial memory feature. Add injection when the task or existing project calls for it.
DTOThe upload payload is separate from the Room row, so transport does not depend on storage annotations.Add a separate DTO when an API boundary needs it; do not duplicate every model by default.
Interviewer follow-up

Does calling a suspend function automatically make blocking work safe?

No. A suspend function can still block its caller. Room suspend methods manage database execution; other blocking boundaries must switch dispatchers inside the data layer.

Do this

  1. Back up the WS04 files. Delete data/MemoryJournalRepository.kt after replacing its use in the ViewModel. Add or replace the listed files as one compile checkpoint.
  2. Root JournalApplication.kt configures Hilt and WorkManager. di/JournalModule.kt supplies the database and repository; di/IoDispatcher.kt owns the qualifier.
  3. sync/SyncQueue.kt and sync/JournalSyncWorker.kt supply the scheduling and worker types. data/remote owns the simulated backend and its upload DTO. We exercise the queue in WS10.
  4. Replace the shared repository, Application/DI, worker, manifest and Activity below; then apply the WS08 files for the same path you chose at WS04. Replace route and ViewModel together. Do not mix constructors.
  5. Run the app. Room now emits the sample nights in descending date order. Open Database Inspector and inspect nights and notes.

app/src/main/java/dev/dchung/sleepjournal/data/RoomJournalRepository.kt

package dev.dchung.sleepjournal.data

import dev.dchung.sleepjournal.model.*
import dev.dchung.sleepjournal.data.local.*
import dev.dchung.sleepjournal.data.remote.DemoBackend
import dev.dchung.sleepjournal.data.remote.NoteUploadDto
import dev.dchung.sleepjournal.di.IoDispatcher
import dev.dchung.sleepjournal.sync.SyncQueue
import java.util.UUID
import javax.inject.Inject
import javax.inject.Singleton
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext

// Add Room only when persistence is part of the requirement.
// The repository exposes domain data, so the UI does not depend on table shapes.
@Singleton
class RoomJournalRepository @Inject constructor(
    private val dao: JournalDao,
    private val backend: DemoBackend,
    private val queue: SyncQueue,
    @IoDispatcher private val io: CoroutineDispatcher
) : JournalRepository {
    // Serialize manual and worker drains in this process.
    // A real server still needs idempotency across retries and devices.
    private val syncMutex = Mutex()
    override val offline = backend.offline
    // Room is the source of truth. Saves and sync update Room; its Flow updates UI.
    override fun observe(): Flow<Journal> = combine(dao.nights(), dao.notes()) { nights, notes ->
        Journal(nights.map { it.toDomain() }, notes.map { it.toDomain() })
    }
    override suspend fun seed() = withContext(io) {
        dao.seed(sampleNights.map { NightEntity(it.id, it.date, it.minutes, it.favorite) })
    }
    override suspend fun toggle(nightId: String) = withContext(io) { dao.toggle(nightId) }
    override suspend fun addNote(nightId: String, text: String) = withContext(io) {
        val clean = text.trim()
        require(clean.isNotEmpty() && clean.length <= 280) { "Write a note of 1 to 280 characters" }
        // Persist first so the note is visible offline. Its UUID is the retry key.
        dao.insert(NoteEntity(UUID.randomUUID().toString(), nightId, clean, false))
        // Schedule an attempt after the durable write. These are separate transactions;
        // startup recovery covers a crash here on the next app launch.
        queue.enqueue()
        Unit
    }
    override suspend fun sync() = withContext(io) {
        syncMutex.withLock {
            for (note in dao.pending()) {
                backend.accept(NoteUploadDto(note.id, note.nightId, note.text))
                dao.acknowledge(note.id)
            }
        }
    }
    override fun setOffline(value: Boolean) {
        backend.setOffline(value)
        if (!value) queue.enqueue()
    }
}

Create or fully replace this file. Type the code before comparing with the final reference.

app/src/main/java/dev/dchung/sleepjournal/data/remote/JournalDtos.kt

package dev.dchung.sleepjournal.data.remote

// This is the payload we send, separate from a Room entity.
// Add a DTO when the API shape differs; do not add one to every simple class.
data class NoteUploadDto(val id: String, val nightId: String, val text: String)

Create or fully replace this file. Type the code before comparing with the final reference.

app/src/main/java/dev/dchung/sleepjournal/data/remote/DemoBackend.kt

package dev.dchung.sleepjournal.data.remote

import dev.dchung.sleepjournal.data.local.DemoReceipt
import dev.dchung.sleepjournal.data.local.JournalDao
import java.io.IOException
import javax.inject.Inject
import javax.inject.Singleton
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.*

// This simulates a remote API using local receipt rows. It makes retry identity
// visible, but cannot prove a real server accepted or retained anything.
@Singleton
class DemoBackend @Inject constructor(private val dao: JournalDao) {
    private val disconnected = MutableStateFlow(false)
    val offline: StateFlow<Boolean> = disconnected.asStateFlow()
    fun setOffline(value: Boolean) { disconnected.value = value }
    suspend fun accept(note: NoteUploadDto) {
        delay(600)
        if (offline.value) throw IOException("Demo backend is offline")
        val receipt = DemoReceipt(note.id, note.nightId, note.text)
        dao.receipt(receipt)
        check(dao.receiptById(note.id) == receipt) { "Operation ID reused with different data" }
    }
}

Create or fully replace this file. Type the code before comparing with the final reference.

app/src/main/java/dev/dchung/sleepjournal/di/IoDispatcher.kt

package dev.dchung.sleepjournal.di

import javax.inject.Qualifier

// The qualifier selects this CoroutineDispatcher binding.
// It is useful for replacing dispatchers, not a new app layer.
@Qualifier @Retention(AnnotationRetention.BINARY) annotation class IoDispatcher

Create or fully replace this file. Type the code before comparing with the final reference.

app/src/main/java/dev/dchung/sleepjournal/di/JournalModule.kt

package dev.dchung.sleepjournal.di

import android.content.Context
import androidx.room.Room
import dagger.Binds
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import dev.dchung.sleepjournal.data.JournalRepository
import dev.dchung.sleepjournal.data.RoomJournalRepository
import dev.dchung.sleepjournal.data.local.JournalDatabase
import dev.dchung.sleepjournal.data.local.JournalDao
import javax.inject.Singleton
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers

// Hilt builds and shares the database/repository for the full workshop.
// For a timed feature, construct a memory repository directly until DI is needed.
@Module @InstallIn(SingletonComponent::class)
object StorageModule {
    @Provides @Singleton
    fun database(@ApplicationContext context: Context): JournalDatabase =
        Room.databaseBuilder(context, JournalDatabase::class.java, "journal.db").build()
    @Provides fun dao(database: JournalDatabase): JournalDao = database.journalDao()
    @Provides @IoDispatcher fun io(): CoroutineDispatcher = Dispatchers.IO
}
@Module @InstallIn(SingletonComponent::class)
abstract class RepositoryModule {
    @Binds abstract fun repository(impl: RoomJournalRepository): JournalRepository
}

Create or fully replace this file. Type the code before comparing with the final reference.

app/src/main/java/dev/dchung/sleepjournal/JournalApplication.kt

package dev.dchung.sleepjournal

import android.app.Application
import androidx.hilt.work.HiltWorkerFactory
import androidx.work.Configuration
import dagger.hilt.android.HiltAndroidApp
import dev.dchung.sleepjournal.sync.SyncQueue
import javax.inject.Inject

// This application wiring supports injected workers and startup recovery.
// Skip it for the initial in-memory interview feature.
@HiltAndroidApp
class JournalApplication : Application(), Configuration.Provider {
    @Inject lateinit var workerFactory: HiltWorkerFactory
    @Inject lateinit var queue: SyncQueue
    override val workManagerConfiguration: Configuration
        get() = Configuration.Builder().setWorkerFactory(workerFactory).build()
    override fun onCreate() {
        super.onCreate()
        queue.enqueue()
    }
}

Create or fully replace this file. Type the code before comparing with the final reference.

app/src/main/java/dev/dchung/sleepjournal/sync/SyncQueue.kt

package dev.dchung.sleepjournal.sync

import android.content.Context
import androidx.work.*
import dagger.hilt.android.qualifiers.ApplicationContext
import java.util.concurrent.TimeUnit
import javax.inject.Inject
import javax.inject.Singleton

@Singleton
class SyncQueue @Inject constructor(@ApplicationContext private val context: Context) {
    fun enqueue() {
        val work = OneTimeWorkRequestBuilder<JournalSyncWorker>()
            .setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 10, TimeUnit.SECONDS).build()
        // Append another drain if a new note arrives during an existing one.
        // WorkManager can delay execution; it is not an exact timer.
        WorkManager.getInstance(context).enqueueUniqueWork("journal-outbox", ExistingWorkPolicy.APPEND_OR_REPLACE, work)
    }
}

Create or fully replace this file. Type the code before comparing with the final reference.

app/src/main/java/dev/dchung/sleepjournal/sync/JournalSyncWorker.kt

package dev.dchung.sleepjournal.sync

import android.content.Context
import androidx.hilt.work.HiltWorker
import androidx.work.CoroutineWorker
import androidx.work.WorkerParameters
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import dev.dchung.sleepjournal.data.JournalRepository
import java.io.IOException
import kotlinx.coroutines.CancellationException

// Use a worker only when a pending write must retry beyond the screen lifetime.
// For a foreground-only feature, viewModelScope is enough.
@HiltWorker
class JournalSyncWorker @AssistedInject constructor(
    @Assisted context: Context,
    @Assisted parameters: WorkerParameters,
    private val repository: JournalRepository
) : CoroutineWorker(context, parameters) {
    override suspend fun doWork(): Result = try {
        repository.sync()
        Result.success()
    // Cancellation belongs to the coroutine owner. Rethrow so stopped work stops.
    } catch (cancelled: CancellationException) {
        throw cancelled
    } catch (offline: IOException) {
        Result.retry()
    } catch (invalid: IllegalStateException) {
        Result.failure()
    }
}

Create or fully replace this file. Type the code before comparing with the final reference.

app/src/main/java/dev/dchung/sleepjournal/MainActivity.kt

package dev.dchung.sleepjournal

import dev.dchung.sleepjournal.ui.journal.JournalRoute

import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme
import dagger.hilt.android.AndroidEntryPoint

@AndroidEntryPoint
class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        enableEdgeToEdge()
        setContent {
            MaterialTheme(colorScheme = if (isSystemInDarkTheme()) darkColorScheme() else lightColorScheme()) {
                JournalRoute()
            }
        }
    }
}

Create or fully replace this file. Type the code before comparing with the final reference.

app/src/main/AndroidManifest.xml

<manifest xmlns:android="http://schemas.android.com/apk/res/android" xmlns:tools="http://schemas.android.com/tools">
    <application android:name="dev.dchung.sleepjournal.JournalApplication" android:label="Sleep journal workshop" android:theme="@android:style/Theme.Material.Light.NoActionBar" android:allowBackup="false" android:supportsRtl="true" android:enableOnBackInvokedCallback="true">
        <activity android:name="dev.dchung.sleepjournal.MainActivity" android:exported="true">
            <intent-filter>
                <action android:name="android.intent.action.MAIN" />
                <category android:name="android.intent.category.LAUNCHER" />
            </intent-filter>
        </activity>
        <provider android:name="androidx.startup.InitializationProvider" android:authorities="${applicationId}.androidx-startup" tools:node="merge">
            <meta-data android:name="androidx.work.WorkManagerInitializer" tools:node="remove" />
        </provider>
    </application>
</manifest>

Create or fully replace this file. Type the code before comparing with the final reference.

MVVM injected final checkpoint

  1. Choose one architecture path and fully replace only the files shown for that path. Do not compile both JournalViewModel classes in one package.
  2. Replace the WS04 ViewModel and route together. The constructor now requires JournalRepository and SavedStateHandle, and the route uses hiltViewModel().
  3. Follow the route adapter from a JournalEvent to a named ViewModel function. Query, filter, selection and draft use direct immutable UserInput copies.
  4. Do not use the shared reduce function in this ViewModel. It stays in ui/journal/JournalPresentation.kt for the existing presentation test examples.

app/src/main/java/dev/dchung/sleepjournal/ui/journal/JournalRoute.kt

package dev.dchung.sleepjournal.ui.journal

import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.hilt.navigation.compose.hiltViewModel

// The route owns Android-specific wiring; the screen only renders values.
// Lifecycle-aware collection pauses when this UI is not active.
@Composable
fun JournalRoute(modifier: Modifier = Modifier, viewModel: JournalViewModel = hiltViewModel()) {
    val state by viewModel.uiState.collectAsStateWithLifecycle()
    // Map the common teaching callback to this path's ViewModel functions or intents.
    JournalScreen(state = state, onEvent = { event ->
        when (event) {
            is JournalEvent.Query -> viewModel.onQueryChanged(event.value)
            is JournalEvent.Favorites -> viewModel.onFavoritesChanged(event.value)
            is JournalEvent.Select -> viewModel.onNightSelected(event.id)
            is JournalEvent.Draft -> viewModel.onDraftChanged(event.value)
            is JournalEvent.Favorite -> viewModel.onFavoriteClicked(event.id)
            JournalEvent.SaveNote -> viewModel.onSaveClicked()
            JournalEvent.Sync -> viewModel.onSyncClicked()
            is JournalEvent.Offline -> viewModel.onOfflineChanged(event.value)
        }
    }, onReload = viewModel::reload, modifier = modifier)
}

Selected architecture checkpoint. Replace route and ViewModel together; never install both variants.

app/src/main/java/dev/dchung/sleepjournal/ui/journal/JournalScreen.kt

package dev.dchung.sleepjournal.ui.journal

import dev.dchung.sleepjournal.model.*

import android.content.res.Configuration
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.testTagsAsResourceId
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp

// A stateless screen is easy to preview and check with fixed inputs.
// Callbacks request changes; neither Room nor the ViewModel is created here.
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun JournalScreen(state: JournalUiState, onEvent: (JournalEvent) -> Unit,
    onReload: () -> Unit, modifier: Modifier = Modifier) {
    BackHandler(enabled = state.userInput.selected != null) { onEvent(JournalEvent.Select(null)) }
    Scaffold(modifier = modifier.semantics { testTagsAsResourceId = true },
        topBar = { TopAppBar(title = { Text("Sleep journal") }) }) { padding ->
        Column(Modifier.padding(padding).padding(horizontal = 16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
            Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
                Text("Demo offline")
                Switch(state.offline, { onEvent(JournalEvent.Offline(it)) }, Modifier.testTag("demo-offline"))
            }
            Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
                Text(if (state.operation == Operation.Busy) "Working" else if (state.offline) "Offline" else "Online",
                    Modifier.testTag("sync-status"))
                Button({ onEvent(JournalEvent.Sync) }, Modifier.testTag("sync-now"),
                    enabled = state.operation != Operation.Busy) { Text("Sync now") }
            }
            if (state.operation is Operation.Failed) Text(state.operation.message, color = MaterialTheme.colorScheme.error)
            if (state.loadState == LoadState.Loading) {
                CircularProgressIndicator(Modifier.testTag("journal-loading"))
            } else if (state.loadState == LoadState.Failure) {
                Text(state.errorMessage ?: "Cannot read journal")
                Button(onReload, Modifier.testTag("journal-retry")) { Text("Retry") }
            } else {
                val selected = state.journal.nights.firstOrNull { it.id == state.userInput.selected }
                if (state.userInput.selected != null && selected == null) {
                    Text("Night is no longer available")
                    Button({ onEvent(JournalEvent.Select(null)) }, Modifier.testTag("back-detail")) { Text("Back") }
                } else if (selected != null) {
                    Button({ onEvent(JournalEvent.Select(null)) }, Modifier.testTag("back-detail")) { Text("Back") }
                    Text(selected.date, style = MaterialTheme.typography.headlineSmall)
                    Text("${selected.minutes / 60}h ${selected.minutes % 60}m")
                    OutlinedTextField(state.userInput.draft, { onEvent(JournalEvent.Draft(it)) },
                        Modifier.fillMaxWidth().testTag("note-input"), label = { Text("Note") })
                    Button({ onEvent(JournalEvent.SaveNote) }, Modifier.testTag("note-save"),
                        enabled = state.userInput.draft.trim().isNotEmpty() && state.operation != Operation.Busy) { Text("Save note") }
                    LazyColumn(Modifier.weight(1f).testTag("note-list")) {
                        items(state.journal.notes.filter { it.nightId == selected.id }, key = { it.id }) { note ->
                            Text("${note.text} · ${note.status}", Modifier.padding(vertical = 8.dp).testTag("note-${note.id}"))
                        }
                    }
                } else {
                    OutlinedTextField(state.userInput.query, { onEvent(JournalEvent.Query(it)) },
                        Modifier.fillMaxWidth().testTag("sleep-query"), label = { Text("Search date") })
                    FilterChip(state.userInput.favoritesOnly, { onEvent(JournalEvent.Favorites(!state.userInput.favoritesOnly)) },
                        { Text("Favorites only") }, Modifier.testTag("favorites-filter"))
                    val nights = filteredNights(state.journal, state.userInput)
                    if (nights.isEmpty()) Text("No nights match", Modifier.testTag("journal-empty"))
                    LazyColumn(Modifier.weight(1f).testTag("night-list")) {
                        // Stable IDs keep a row's identity when filtering changes its position.
                        items(nights, key = { it.id }) { night ->
                            ListItem(headlineContent = {
                                TextButton({ onEvent(JournalEvent.Select(night.id)) }, Modifier.testTag("night-${night.id}")) {
                                    Text(night.date)
                                }
                            }, supportingContent = { Text("${night.minutes / 60}h ${night.minutes % 60}m") },
                                trailingContent = {
                                    TextButton({ onEvent(JournalEvent.Favorite(night.id)) }, Modifier.testTag("favorite-${night.id}"),
                                        enabled = state.operation != Operation.Busy) { Text(if (night.favorite) "Unsave" else "Save") }
                                })
                        }
                    }
                }
            }
        }
    }
}
@Preview
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES, fontScale = 2f)
@Composable
private fun JournalPreview() { MaterialTheme { JournalScreen(JournalUiState(loadState = LoadState.Ready, journal = Journal(sampleNights, emptyList())), {}, {}) } }
@Preview
@Composable
private fun LoadingPreview() { MaterialTheme { JournalScreen(JournalUiState(), {}, {}) } }
@Preview
@Composable
private fun FailurePreview() { MaterialTheme { JournalScreen(JournalUiState(loadState = LoadState.Failure, errorMessage = "Cannot read journal"), {}, {}) } }
@Preview
@Composable
private fun EmptyPreview() { MaterialTheme { JournalScreen(JournalUiState(loadState = LoadState.Ready), {}, {}) } }

Selected architecture checkpoint. Replace route and ViewModel together; never install both variants.

app/src/main/java/dev/dchung/sleepjournal/ui/journal/JournalViewModel.kt

package dev.dchung.sleepjournal.ui.journal

import dev.dchung.sleepjournal.model.*
import dev.dchung.sleepjournal.data.JournalRepository

import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch

@OptIn(ExperimentalCoroutinesApi::class)
@HiltViewModel
class JournalViewModel @Inject constructor(
    private val repository: JournalRepository,
    private val saved: SavedStateHandle
) : ViewModel() {
    // The ViewModel retains screen inputs through rotation. SavedStateHandle
    // restores small inputs after eligible recreation; it is not a database.
    private val userInput = MutableStateFlow(UserInput(
        query = saved["query"] ?: "", favoritesOnly = saved["favorites"] ?: false,
        selected = saved.get<String>("selected"), draft = saved["draft"] ?: ""))
    private val operation = MutableStateFlow<Operation>(Operation.Idle)
    private val retry = MutableStateFlow(0)
    // Retry starts a fresh read subscription after a terminal read failure.
    // Rethrow cancellation so leaving the owner can stop upstream work.
    private val readFlow: Flow<JournalUiState> = retry.flatMapLatest {
        repository.observe().map { JournalUiState(loadState = LoadState.Ready, journal = it) }
            .onStart { emit(JournalUiState()) }
            .catch { failure ->
                if (failure is CancellationException) throw failure
                emit(JournalUiState(loadState = LoadState.Failure,
                    errorMessage = failure.message ?: "Cannot read journal"))
            }
    }
    // Combine the latest data and inputs into one screen snapshot.
    // stateIn exposes a hot StateFlow with a current value; this upstream shares
    // one subscription while observed and stops after five seconds without collectors.
    val uiState: StateFlow<JournalUiState> = combine(readFlow, userInput, operation, repository.offline) { readState, input, op, offline ->
        readState.copy(userInput = input, operation = op, offline = offline)
    }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), JournalUiState())
    init { runOperation { repository.seed() } }
    fun reload() { retry.update { it + 1 } }
    // MVVM uses named functions and copy to update immutable inputs directly.
    fun onQueryChanged(value: String) = updateUserInput(userInput.value.copy(query = value))
    fun onFavoritesChanged(value: Boolean) = updateUserInput(userInput.value.copy(favoritesOnly = value))
    fun onNightSelected(id: String?) = updateUserInput(userInput.value.copy(selected = id, draft = ""))
    fun onDraftChanged(value: String) = updateUserInput(userInput.value.copy(draft = value))
    fun onFavoriteClicked(id: String) = runOperation { repository.toggle(id) }
    fun onOfflineChanged(value: Boolean) = repository.setOffline(value)
    fun onSyncClicked() = runOperation { repository.sync() }
    fun onSaveClicked() {
        val form = userInput.value
        val id = form.selected ?: return
        runOperation {
            repository.addNote(id, form.draft)
            // Do not clear a newer draft typed while this save was running.
            if (userInput.value.selected == id && userInput.value.draft == form.draft) {
                updateUserInput(userInput.value.copy(draft = ""))
            }
        }
    }
    private fun updateUserInput(value: UserInput) {
        userInput.value = value
        saved["query"] = value.query
        saved["favorites"] = value.favoritesOnly
        saved["selected"] = value.selected
        saved["draft"] = value.draft
    }
    private fun runOperation(block: suspend () -> Unit) {
        if (operation.value == Operation.Busy) return
        operation.value = Operation.Busy
        // The ViewModel owns this work; clearing it cancels the coroutine.
        // A durable note lives in Room and can be retried by a worker later.
        viewModelScope.launch {
            try { block(); operation.value = Operation.Idle }
            catch (cancelled: CancellationException) { operation.value = Operation.Idle; throw cancelled }
            catch (failure: Exception) { operation.value = Operation.Failed(failure.message ?: "Operation failed") }
        }
    }
}

Selected architecture checkpoint. Replace route and ViewModel together; never install both variants.

This MVVM example exposes named ViewModel functions and updates immutable state directly. The screen uses a shared event callback for reusable rendering and tests; the route maps that UI contract to direct calls. Typed callbacks are compatible with MVVM, so event syntax alone does not identify MVI.

MVI injected final checkpoint

  1. Choose one architecture path and fully replace only the files shown for that path. Do not compile both JournalViewModel classes in one package.
  2. Replace the WS04 ViewModel and route together. The constructor now requires JournalRepository and SavedStateHandle, and the route uses hiltViewModel().
  3. Follow the route adapter from a JournalEvent to JournalIntent. onIntent first applies the pure reducer, then owns any coroutine or repository work.
  4. Keep JournalIntent.kt and JournalReducer.kt beside the ViewModel. JournalResult.NoteSaved re-enters the pure reducer after a save; repository journal data and operation state still come from their existing flows.

app/src/main/java/dev/dchung/sleepjournal/ui/journal/JournalIntent.kt

package dev.dchung.sleepjournal.ui.journal

import dev.dchung.sleepjournal.model.*

// MVI names every user action so transitions have explicit inputs.
// Use named ViewModel functions instead if they are easier to finish and explain.
sealed interface JournalIntent {
    data class QueryChanged(val value: String) : JournalIntent
    data class FavoritesChanged(val value: Boolean) : JournalIntent
    data class NightSelected(val id: String?) : JournalIntent
    data class DraftChanged(val value: String) : JournalIntent
    data class FavoriteClicked(val id: String) : JournalIntent
    data object SaveClicked : JournalIntent
    data object SyncClicked : JournalIntent
    data class OfflineChanged(val value: Boolean) : JournalIntent
    data object RetryRead : JournalIntent
}
// A save finishes later. Carry the submitted values back so completion
// can avoid clearing a newer draft or a different selected night.
sealed interface JournalResult {
    data class NoteSaved(val nightId: String, val submittedDraft: String) : JournalResult
}

Selected architecture checkpoint. Replace route and ViewModel together; never install both variants.

app/src/main/java/dev/dchung/sleepjournal/ui/journal/JournalReducer.kt

package dev.dchung.sleepjournal.ui.journal

import dev.dchung.sleepjournal.model.*

// A reducer returns the next UserInput value. Same inputs give the same output.
// It does not launch work or write storage; the ViewModel owns those effects.
fun reduceJournal(userInput: UserInput, intent: JournalIntent): UserInput = when (intent) {
    is JournalIntent.QueryChanged -> userInput.copy(query = intent.value)
    is JournalIntent.FavoritesChanged -> userInput.copy(favoritesOnly = intent.value)
    is JournalIntent.NightSelected -> userInput.copy(selected = intent.id, draft = "")
    is JournalIntent.DraftChanged -> userInput.copy(draft = intent.value)
    is JournalIntent.FavoriteClicked, JournalIntent.SaveClicked, JournalIntent.SyncClicked,
    is JournalIntent.OfflineChanged, JournalIntent.RetryRead -> userInput
}
// Only clear the draft that actually finished saving.
// This reducer owns userInput/results, not every JournalUiState transition.
fun reduceJournal(userInput: UserInput, result: JournalResult): UserInput = when (result) {
    is JournalResult.NoteSaved ->
        if (userInput.selected == result.nightId && userInput.draft == result.submittedDraft)
            userInput.copy(draft = "") else userInput
}

Selected architecture checkpoint. Replace route and ViewModel together; never install both variants.

app/src/main/java/dev/dchung/sleepjournal/ui/journal/JournalRoute.kt

package dev.dchung.sleepjournal.ui.journal

import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.hilt.navigation.compose.hiltViewModel

// The route owns Android-specific wiring; the screen only renders values.
// Lifecycle-aware collection pauses when this UI is not active.
@Composable
fun JournalRoute(modifier: Modifier = Modifier, viewModel: JournalViewModel = hiltViewModel()) {
    val state by viewModel.uiState.collectAsStateWithLifecycle()
    // Map the common teaching callback to this path's ViewModel functions or intents.
    JournalScreen(state = state, onEvent = { event ->
        val intent = when (event) {
            is JournalEvent.Query -> JournalIntent.QueryChanged(event.value)
            is JournalEvent.Favorites -> JournalIntent.FavoritesChanged(event.value)
            is JournalEvent.Select -> JournalIntent.NightSelected(event.id)
            is JournalEvent.Draft -> JournalIntent.DraftChanged(event.value)
            is JournalEvent.Favorite -> JournalIntent.FavoriteClicked(event.id)
            JournalEvent.SaveNote -> JournalIntent.SaveClicked
            JournalEvent.Sync -> JournalIntent.SyncClicked
            is JournalEvent.Offline -> JournalIntent.OfflineChanged(event.value)
        }
        viewModel.onIntent(intent)
    }, onReload = { viewModel.onIntent(JournalIntent.RetryRead) }, modifier = modifier)
}

Selected architecture checkpoint. Replace route and ViewModel together; never install both variants.

app/src/main/java/dev/dchung/sleepjournal/ui/journal/JournalScreen.kt

package dev.dchung.sleepjournal.ui.journal

import dev.dchung.sleepjournal.model.*

import android.content.res.Configuration
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.testTagsAsResourceId
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp

// A stateless screen is easy to preview and check with fixed inputs.
// Callbacks request changes; neither Room nor the ViewModel is created here.
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun JournalScreen(state: JournalUiState, onEvent: (JournalEvent) -> Unit,
    onReload: () -> Unit, modifier: Modifier = Modifier) {
    BackHandler(enabled = state.userInput.selected != null) { onEvent(JournalEvent.Select(null)) }
    Scaffold(modifier = modifier.semantics { testTagsAsResourceId = true },
        topBar = { TopAppBar(title = { Text("Sleep journal") }) }) { padding ->
        Column(Modifier.padding(padding).padding(horizontal = 16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
            Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
                Text("Demo offline")
                Switch(state.offline, { onEvent(JournalEvent.Offline(it)) }, Modifier.testTag("demo-offline"))
            }
            Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
                Text(if (state.operation == Operation.Busy) "Working" else if (state.offline) "Offline" else "Online",
                    Modifier.testTag("sync-status"))
                Button({ onEvent(JournalEvent.Sync) }, Modifier.testTag("sync-now"),
                    enabled = state.operation != Operation.Busy) { Text("Sync now") }
            }
            if (state.operation is Operation.Failed) Text(state.operation.message, color = MaterialTheme.colorScheme.error)
            if (state.loadState == LoadState.Loading) {
                CircularProgressIndicator(Modifier.testTag("journal-loading"))
            } else if (state.loadState == LoadState.Failure) {
                Text(state.errorMessage ?: "Cannot read journal")
                Button(onReload, Modifier.testTag("journal-retry")) { Text("Retry") }
            } else {
                val selected = state.journal.nights.firstOrNull { it.id == state.userInput.selected }
                if (state.userInput.selected != null && selected == null) {
                    Text("Night is no longer available")
                    Button({ onEvent(JournalEvent.Select(null)) }, Modifier.testTag("back-detail")) { Text("Back") }
                } else if (selected != null) {
                    Button({ onEvent(JournalEvent.Select(null)) }, Modifier.testTag("back-detail")) { Text("Back") }
                    Text(selected.date, style = MaterialTheme.typography.headlineSmall)
                    Text("${selected.minutes / 60}h ${selected.minutes % 60}m")
                    OutlinedTextField(state.userInput.draft, { onEvent(JournalEvent.Draft(it)) },
                        Modifier.fillMaxWidth().testTag("note-input"), label = { Text("Note") })
                    Button({ onEvent(JournalEvent.SaveNote) }, Modifier.testTag("note-save"),
                        enabled = state.userInput.draft.trim().isNotEmpty() && state.operation != Operation.Busy) { Text("Save note") }
                    LazyColumn(Modifier.weight(1f).testTag("note-list")) {
                        items(state.journal.notes.filter { it.nightId == selected.id }, key = { it.id }) { note ->
                            Text("${note.text} · ${note.status}", Modifier.padding(vertical = 8.dp).testTag("note-${note.id}"))
                        }
                    }
                } else {
                    OutlinedTextField(state.userInput.query, { onEvent(JournalEvent.Query(it)) },
                        Modifier.fillMaxWidth().testTag("sleep-query"), label = { Text("Search date") })
                    FilterChip(state.userInput.favoritesOnly, { onEvent(JournalEvent.Favorites(!state.userInput.favoritesOnly)) },
                        { Text("Favorites only") }, Modifier.testTag("favorites-filter"))
                    val nights = filteredNights(state.journal, state.userInput)
                    if (nights.isEmpty()) Text("No nights match", Modifier.testTag("journal-empty"))
                    LazyColumn(Modifier.weight(1f).testTag("night-list")) {
                        // Stable IDs keep a row's identity when filtering changes its position.
                        items(nights, key = { it.id }) { night ->
                            ListItem(headlineContent = {
                                TextButton({ onEvent(JournalEvent.Select(night.id)) }, Modifier.testTag("night-${night.id}")) {
                                    Text(night.date)
                                }
                            }, supportingContent = { Text("${night.minutes / 60}h ${night.minutes % 60}m") },
                                trailingContent = {
                                    TextButton({ onEvent(JournalEvent.Favorite(night.id)) }, Modifier.testTag("favorite-${night.id}"),
                                        enabled = state.operation != Operation.Busy) { Text(if (night.favorite) "Unsave" else "Save") }
                                })
                        }
                    }
                }
            }
        }
    }
}
@Preview
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES, fontScale = 2f)
@Composable
private fun JournalPreview() { MaterialTheme { JournalScreen(JournalUiState(loadState = LoadState.Ready, journal = Journal(sampleNights, emptyList())), {}, {}) } }
@Preview
@Composable
private fun LoadingPreview() { MaterialTheme { JournalScreen(JournalUiState(), {}, {}) } }
@Preview
@Composable
private fun FailurePreview() { MaterialTheme { JournalScreen(JournalUiState(loadState = LoadState.Failure, errorMessage = "Cannot read journal"), {}, {}) } }
@Preview
@Composable
private fun EmptyPreview() { MaterialTheme { JournalScreen(JournalUiState(loadState = LoadState.Ready), {}, {}) } }

Selected architecture checkpoint. Replace route and ViewModel together; never install both variants.

app/src/main/java/dev/dchung/sleepjournal/ui/journal/JournalViewModel.kt

package dev.dchung.sleepjournal.ui.journal

import dev.dchung.sleepjournal.model.*
import dev.dchung.sleepjournal.data.JournalRepository

import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch

@OptIn(ExperimentalCoroutinesApi::class)
@HiltViewModel
class JournalViewModel @Inject constructor(
    private val repository: JournalRepository,
    private val saved: SavedStateHandle
) : ViewModel() {
    // The ViewModel retains screen inputs through rotation. SavedStateHandle
    // restores small inputs after eligible recreation; it is not a database.
    private val userInput = MutableStateFlow(UserInput(
        query = saved["query"] ?: "", favoritesOnly = saved["favorites"] ?: false,
        selected = saved.get<String>("selected"), draft = saved["draft"] ?: ""))
    private val operation = MutableStateFlow<Operation>(Operation.Idle)
    private val retry = MutableStateFlow(0)
    // Retry starts a fresh read subscription after a terminal read failure.
    // Rethrow cancellation so leaving the owner can stop upstream work.
    private val readFlow: Flow<JournalUiState> = retry.flatMapLatest {
        repository.observe().map { JournalUiState(loadState = LoadState.Ready, journal = it) }
            .onStart { emit(JournalUiState()) }
            .catch { failure ->
                if (failure is CancellationException) throw failure
                emit(JournalUiState(loadState = LoadState.Failure,
                    errorMessage = failure.message ?: "Cannot read journal"))
            }
    }
    // Combine the latest data and inputs into one screen snapshot.
    // stateIn exposes a hot StateFlow with a current value; this upstream shares
    // one subscription while observed and stops after five seconds without collectors.
    val uiState: StateFlow<JournalUiState> = combine(readFlow, userInput, operation, repository.offline) { readState, input, op, offline ->
        readState.copy(userInput = input, operation = op, offline = offline)
    }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), JournalUiState())
    init { runOperation { repository.seed() } }
    // The reducer changes input state; this ViewModel performs save/sync effects.
    fun onIntent(intent: JournalIntent) {
        updateUserInput(reduceJournal(userInput.value, intent))
        when (intent) {
            is JournalIntent.FavoriteClicked -> runOperation { repository.toggle(intent.id) }
            JournalIntent.SaveClicked -> {
                val form = userInput.value
                val id = form.selected ?: return
                runOperation {
                    repository.addNote(id, form.draft)
                    updateUserInput(reduceJournal(userInput.value, JournalResult.NoteSaved(id, form.draft)))
                }
            }
            JournalIntent.SyncClicked -> runOperation { repository.sync() }
            is JournalIntent.OfflineChanged -> repository.setOffline(intent.value)
            JournalIntent.RetryRead -> retry.update { it + 1 }
            is JournalIntent.QueryChanged, is JournalIntent.FavoritesChanged,
            is JournalIntent.NightSelected, is JournalIntent.DraftChanged -> Unit
        }
    }
    private fun updateUserInput(value: UserInput) {
        userInput.value = value
        saved["query"] = value.query
        saved["favorites"] = value.favoritesOnly
        saved["selected"] = value.selected
        saved["draft"] = value.draft
    }
    private fun runOperation(block: suspend () -> Unit) {
        if (operation.value == Operation.Busy) return
        operation.value = Operation.Busy
        // The ViewModel owns this work; clearing it cancels the coroutine.
        // A durable note lives in Room and can be retried by a worker later.
        viewModelScope.launch {
            try { block(); operation.value = Operation.Idle }
            catch (cancelled: CancellationException) { operation.value = Operation.Idle; throw cancelled }
            catch (failure: Exception) { operation.value = Operation.Failed(failure.message ?: "Operation failed") }
        }
    }
}

Selected architecture checkpoint. Replace route and ViewModel together; never install both variants.

This MVI example adds explicit typed intents, pure input/result transitions, and ViewModel-owned effects. A reducer cannot call Room, launch a coroutine, or write SavedStateHandle. MVVM and MVI overlap: Android ViewModel is the lifecycle owner in both, and neither approach requires a new framework.

Explain it, then change it yourself

Hilt constructs repositories and workers. Singleton scopes share the backend and sync mutex. Repository functions switch to IO themselves; callers remain main-safe. Room suspend queries already handle their execution. This app uses one dispatcher qualifier to make any future blocking boundary explicit.

Fundamentals: AF03 · AF07 · AF10 · AF12
Practice problems: EP01 · EP05 · EP08

WS09Save offline and distinguish acceptance from acknowledgement

Make a note useful before synchronization succeeds.

PieceWhy we add itKeep it simple
Local source of truthThe UI observes Room, so offline saves and later acknowledgements use the same read path.For an online-only prompt, handle its loading and save result without adding offline synchronization.
Interviewer follow-up

Does this prove exactly-once delivery?

No. The stable ID supports deduplication, but a real server must retain and enforce idempotency. This backend simulates receipts locally.

Do this

  1. Turn on Demo offline before saving a new note.
  2. Save Window was cold. Inspect the Pending note row and its UUID in Database Inspector.
  3. Press Sync now while offline. Keep the note and show an operation error. Turn offline off and press Sync now again.
  4. Inspect demo_receipts. The receipt operation ID matches the note ID.

Explain it, then change it yourself

The UI reads Room, not the simulated backend return value. Pending means stored locally, not confirmed remotely. The note UUID is created once at insertion and reused on each attempt. The simulated server receipt is also persisted locally for this exercise, so it survives this app process; it does not model a real independent server.

Fundamentals: AF04 · AF10 · AF11
Practice problems: EP01 · EP08 · EP09

WS10Retry the durable queue across lifecycle changes

Separate immediate user work from deferred durable work.

PieceWhy we add itKeep it simple
WorkManagerIt schedules retryable work independently of a visible screen.Use viewModelScope when completing the action only while its owner exists is acceptable.
Startup reconciliationA crash can happen between inserting a note and enqueueing its worker. Startup schedules another drain.Explain that this recovery happens on the next launch; the two transactions are not atomic.
Interviewer follow-up

Will WorkManager run exactly when the backoff expires?

No. The OS can delay it, and force-stop pauses scheduling until launch. The pending row remains durable while waiting.

Do this

  1. Read JournalSyncWorker.doWork. IOException retries with backoff; cancellation propagates.
  2. Save offline, return to the home screen, then reopen the app. Turn offline off and wait for acknowledgement.
  3. Compare the manual Sync now call in viewModelScope with the worker. Both use the same repository mutex and stable operation IDs.
  4. Read Application.onCreate. It schedules reconciliation even after a crash between note insertion and enqueue.

Explain it, then change it yourself

Room insertion and WorkManager enqueue are separate transactions. Startup scheduling recovers their crash gap on the next launch, not atomically while the app remains unopened. Workers can be delayed by the OS, force-stop pauses scheduling until launch, and backoff is not an exact timer. A real API needs server-side idempotency retention, conflict policy, authentication, connectivity constraints, and observability.

Fundamentals: AF03 · AF11
Practice problems: EP01 · EP05 · EP09 · EP11

WS11Keep failures recoverable and scopes deliberate

Explain who owns a coroutine and what survives its cancellation.

PieceWhy we add itKeep it simple
viewModelScopeIt connects foreground work to the ViewModel lifetime instead of a global coroutine.No custom scope is needed. Durable retries belong to the worker only if the requirement needs them.
Interviewer follow-up

Does going to the background cancel viewModelScope?

No. It is cancelled when the ViewModel is cleared. Lifecycle-aware UI collection can stop in the background, and WhileSubscribed can then stop its upstream separately.

Do this

  1. Open your selected JournalViewModel.runOperation. Both paths use the same coroutine owner and catch branches; name the owner and read each branch.
  2. Try a note longer than 280 characters. Confirm the validation error and that no new note enters Room.
  3. Read readFlow.catch and the retry counter. In MVVM the screen calls reload(); in MVI it dispatches RetryRead. Both start a new observable stream after a terminal read error.
  4. Turn the app background for longer than five seconds. Room collection stops if there are no other collectors. Worker ownership does not depend on that collection.

Explain it, then change it yourself

viewModelScope follows the ViewModel lifetime, not a single recomposition. A regular structured child failure can cancel siblings; supervisor ownership isolates appropriate independent work. This app catches expected operation failures at the UI boundary and preserves cancellation. Room holds durable data; SavedStateHandle holds small restoration inputs.

Fundamentals: AF02 · AF03 · AF04 · AF07
Practice problems: EP01 · EP06 · EP09

WS12Trace your chosen path and optionally compare the other

Follow one user event through the owner that changes its state.

PieceWhy we add itKeep it simple
Pure MVI reducerThe next UserInput value depends only on its current value and input. Effects stay in the ViewModel.Use copy in named MVVM functions when that is clearer. Neither path requires a store framework.
Save completion ownershipAn old save must not clear a newer draft or the draft for another selected night.Compare the captured selection and draft before clearing. No new abstraction is needed.
Interviewer follow-up

Does this MVI reducer own the entire UiState?

No. It owns UserInput and save-completion transitions. Repository data and operation status remain in their existing flows; the ViewModel combines them.

Do this

  1. MVVM: trace a query callback through the route adapter, onQueryChanged, UserInput.copy, combine, uiState and lifecycle-aware screen collection.
  2. MVI: trace QueryChanged through onIntent, reduceJournal, userInput, combine, uiState and lifecycle-aware screen collection.
  3. Trace save separately: both ViewModels capture selected ID/draft, write through the repository, and let Room observation update journal data. MVVM checks ownership before clearing; MVI feeds NoteSaved into its pure reducer.
  4. Open another night or type a new draft during a save. Completion must not clear another selection’s draft.
  5. Optional comparison after WS08: back up the working project, replace only ui/journal/JournalViewModel.kt and ui/journal/JournalRoute.kt with the other final path, and add/remove JournalIntent.kt and JournalReducer.kt as appropriate. Never add a second class with the same package/name. Shared data, DI, manifest, Activity and tests stay the same.
  6. Do not switch during an interview unless the prompt requires it. Pick the simplest approach you can explain and finish.

Explain it, then change it yourself

MVVM describes presentation layers, while MVI emphasizes typed inputs and predictable state transitions. An Android ViewModel can implement either. The shared screen emits JournalEvent for testability; MVVM routes these to functions and MVI routes them to intents. This small MVI reducer owns screen inputs/results, while repository data and operation flows retain their existing owners. A single reducer for every JournalUiState transition is an optional store-style extension; it is not implemented or needed here. No store library, additional module or use-case wrapper is required.

Fundamentals: AF02 · AF09
Practice problems: EP01 · EP02 · EP03 · EP08

WS13Observe composition, layout, and drawing separately

Move a state read to the phase that needs it and observe the difference.

PieceWhy we add itKeep it simple
Optional phase labIt demonstrates that a draw-only state read can invalidate drawing without requiring that composable to recompose.Normal screen state is fine. Do not add phase tricks without a measured problem.
Interviewer follow-up

Does every redraw imply recomposition?

No. Composition, layout and drawing are distinct phases. Which work is invalidated depends on where the state is read.

Do this

  1. Create ui/journal/PhaseLab.kt. In MainActivity temporarily replace JournalRoute() with PhaseLab() and replace its import with dev.dchung.sleepjournal.ui.journal.PhaseLab.
  2. Run and move the slider. Filter Logcat by PhaseLab, then inspect recomposition counts in Layout Inspector.
  3. Compare CompositionMeter, DrawOnlyMeter, and LayoutOnlyDot. Each reads the same state in a different phase.
  4. Restore JournalRoute() and its import after the experiment. Keep PhaseLab as an optional exercise file.

app/src/main/java/dev/dchung/sleepjournal/ui/journal/PhaseLab.kt

package dev.dchung.sleepjournal.ui.journal

import android.util.Log
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Slider
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import kotlin.math.roundToInt

// This optional lab isolates where state is read. Optimize phases after
// measuring a frequent update, not before the first working screen.
@Composable
fun PhaseLab(modifier: Modifier = Modifier) {
    val progress = remember { mutableFloatStateOf(0.5f) }
    SideEffect { Log.d("PhaseLab", "Parent composed") }
    Column(modifier.padding(16.dp)) {
        PhaseControls(progress)
        CompositionMeter({ progress.floatValue })
        DrawOnlyMeter({ progress.floatValue })
        LayoutOnlyDot({ progress.floatValue })
    }
}
@Composable
private fun PhaseControls(progress: MutableFloatState) {
    Slider(progress.floatValue, { progress.floatValue = it }, Modifier.testTag("phase-slider"))
}
@Composable
private fun CompositionMeter(progress: () -> Float) {
    val fraction = progress()
    val color = MaterialTheme.colorScheme.primary
    SideEffect { Log.d("PhaseLab", "Composition meter composed") }
    Canvas(Modifier.fillMaxWidth().height(24.dp).testTag("composition-meter")) {
        drawRect(color, size = size.copy(width = size.width * fraction))
    }
}
@Composable
private fun DrawOnlyMeter(progress: () -> Float) {
    val color = MaterialTheme.colorScheme.secondary
    SideEffect { Log.d("PhaseLab", "Draw-only meter composed") }
    Canvas(Modifier.fillMaxWidth().height(24.dp).testTag("draw-meter")) {
        drawRect(color, size = size.copy(width = size.width * progress()))
        Log.d("PhaseLab", "Draw-only meter drew")
    }
}
@Composable
private fun LayoutOnlyDot(progress: () -> Float) {
    val color = MaterialTheme.colorScheme.tertiary
    SideEffect { Log.d("PhaseLab", "Layout-only dot composed") }
    Box(Modifier.width(100.dp).height(24.dp)) {
        Box(Modifier.offset {
            Log.d("PhaseLab", "Layout-only dot placed")
            IntOffset((84.dp.toPx() * progress()).roundToInt(), 0)
        }.size(16.dp).background(color).testTag("layout-dot"))
    }
}

Create or fully replace this file. Type the code before comparing with the final reference.

Explain it, then change it yourself

A state read registers an invalidation dependency in the phase where it occurs. Recomposition, layout, and redraw are different work; Android can redraw without recomposing. Phase deferral is useful for measured frequent visual changes, not a reason to avoid simple screen state. This debug log experiment is not a performance benchmark.

Fundamentals: AF17 · AF18
Practice problems: EP02 · EP03 · EP10 · EP11

WS14Check pure behavior and coroutine contracts with unit tests

Turn the observed behavior into deterministic checks.

PieceWhy we add itKeep it simple
Existing unit checksPure filtering and Flow ownership can be checked without an emulator.In a short interview, check the requested behavior first and run relevant existing checks when available.
Interviewer follow-up

Do these passing tests prove Room and Hilt work?

No. They check presentation helpers and Flow contracts. Generated database/injection code, path-specific ViewModels and device behavior need their own verification.

Do this

  1. Create JournalModelsTest.kt. Run it and the FlowOwnershipTest added in WS05.
  2. These existing domain and Flow tests are shared by both paths. JournalModelsTest exercises the shared reduce example; it does not prove the MVVM direct functions or the path-specific MVI reducer/ViewModel. Keep that coverage boundary explicit.
  3. Read each asserted output before running. No Android device is needed for these domain and coroutine tests.
  4. Run the tests using Gradle on your own machine. The author separately compiled these pure files and ran their six tests with the Kotlin CLI.

app/src/test/java/dev/dchung/sleepjournal/ui/journal/JournalModelsTest.kt

package dev.dchung.sleepjournal.ui.journal

import dev.dchung.sleepjournal.model.*

import org.junit.Assert.*
import org.junit.Test

class JournalModelsTest {
    @Test fun favoritesAndQueryMustBothMatch() {
        val journal = Journal(listOf(sampleNights[0].copy(favorite = true), sampleNights[1]), emptyList())
        assertEquals(listOf("n1"), filteredNights(journal, UserInput(" 10-01 ", true)).map { it.id })
        assertEquals(emptyList<SleepNight>(), filteredNights(journal, UserInput("10-02", true)))
    }
    @Test fun choosingAnotherNightClearsThePreviousDraft() {
        assertEquals(UserInput(selected = "n2"), reduce(UserInput(selected = "n1", draft = "private draft"), JournalEvent.Select("n2")))
    }
    @Test fun typingDoesNotChangeSelectionOrFavoriteFilter() {
        val before = UserInput(favoritesOnly = true, selected = "n1")
        assertEquals(before.copy(draft = "Slept well"), reduce(before, JournalEvent.Draft("Slept well")))
    }
}

Create or fully replace this file. Type the code before comparing with the final reference.

Explain it, then change it yourself

The tests check filtering results, draft ownership, producer starts, subscription timeout, and stale search cancellation. They do not prove Room migrations, WorkerFactory wiring, or Compose device behavior. A complete production suite would add repository, WorkManager, persistence, and failure integration checks. Both architecture paths retain these test symbols. Their six tests are shared examples, not architecture-variant ViewModel or reducer integration coverage.

Fundamentals: AF13 · AF04 · AF05 · AF06
Practice problems: EP01 · EP04 · EP05 · EP09

WS15Drive Compose controls with UI tests

Exercise visible behavior through the screen contract.

PieceWhy we add itKeep it simple
Stable semantics tagsChecks can identify a control reliably without relying on its screen position.Use the existing screen contract. No Hilt or Room is needed to render fixed state.
Interviewer follow-up

What would you verify after a stateless screen check?

Run the real feature, rotate, relaunch, and check persistence or offline behavior if required. A screen-only check does not cover the route or repository.

Do this

  1. Create JournalScreenTest.kt under app/src/androidTest/java/dev/dchung/sleepjournal/ui/journal. Its package matches the screen package.
  2. Both paths keep the same JournalScreen(state, onEvent, onReload) contract and JournalEvent type, so this test file needs no architecture-specific rewrite. The route adapters and ViewModels are outside these stateless screen tests.
  3. Start an emulator from Android Studio and run connectedDebugAndroidTest.
  4. The tests render a stateless screen with observable fake UI state. They do not instantiate Hilt, Room, or a network client.
  5. Run the full app manually after the screen tests to cover persistence and offline synchronization.

app/src/androidTest/java/dev/dchung/sleepjournal/ui/journal/JournalScreenTest.kt

package dev.dchung.sleepjournal.ui.journal

import dev.dchung.sleepjournal.model.*

import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.mutableStateOf
import androidx.compose.ui.test.*
import androidx.compose.ui.test.junit4.createComposeRule
import org.junit.Assert.assertEquals
import org.junit.Rule
import org.junit.Test

class JournalScreenTest {
    @get:Rule val compose = createComposeRule()
    @Test fun filteringAndOpeningDetailUseTheVisibleControls() {
        val state = mutableStateOf(JournalUiState(loadState = LoadState.Ready, journal = Journal(sampleNights, emptyList())))
        compose.setContent {
            MaterialTheme {
                JournalScreen(state.value, { event -> state.value = state.value.copy(userInput = reduce(state.value.userInput, event)) }, {})
            }
        }
        compose.onNodeWithTag("sleep-query").performTextInput("10-02")
        compose.onNodeWithTag("night-n1").assertDoesNotExist()
        compose.onNodeWithTag("night-n2").performClick()
        compose.onNodeWithTag("note-input").assertIsDisplayed()
        compose.onNodeWithTag("back-detail").performClick()
        compose.onNodeWithTag("sleep-query").assertTextContains("10-02")
    }
    @Test fun savingEmitsAnEventAndPendingNotesStayVisible() {
        var saved = 0
        val journal = Journal(sampleNights, listOf(Note("op1", "n1", "Window was cold", SyncStatus.Pending)))
        val state = JournalUiState(loadState = LoadState.Ready, journal = journal,
            userInput = UserInput(selected = "n1", draft = "New note"), offline = true)
        compose.setContent { MaterialTheme { JournalScreen(state, { if (it == JournalEvent.SaveNote) saved++ }, {}) } }
        compose.onNodeWithTag("note-op1").assertTextContains("Pending")
        compose.onNodeWithTag("note-save").performClick()
        compose.runOnIdle { assertEquals(1, saved) }
        compose.onNodeWithTag("sync-status").assertTextEquals("Offline")
    }
}

Create or fully replace this file. Type the code before comparing with the final reference.

Explain it, then change it yourself

Compose synchronizes test actions with its UI work. Virtual coroutine time and Compose animation time are different clocks. Stable tags select controls; assertions must verify results. Stateless screen tests are a first layer and do not replace an end-to-end device run through the Activity and storage.

Fundamentals: AF09 · AF14 · AF17
Practice problems: EP01 · EP02 · EP05 · EP08 · EP09

WS16Rebuild the core in thirty minutes and transfer it

Practice choosing the smallest feature scope under interview timing.

PieceWhy we add itKeep it simple
MVP firstA runnable feature demonstrates the behavior and leaves time to check it.Use memory, MVVM functions, plain strings and a small UiState. Add the rest only when the prompt needs it.
Interviewer follow-up

What would you cut first if time was running out?

Optional setup and architecture layers, not the required user interaction. I would tell the interviewer what remains and show the behavior I have actually run.

Do this

  1. Create a second empty project. Do not copy the final reference into it.
  2. Spend five minutes on models and a fake repository, ten on ViewModel and lifecycle-aware list collection, ten on one interaction, and five on error or empty state and a check.
  3. Choose EP01 chat or EP08 sleep history. Map night IDs to message or row IDs and implement the requested feature.
  4. Add Room, Hilt, and durable work only when the prompt asks for persistence or synchronization. Explain the extension rather than spending the entire short interview on setup.
  5. Compare your result with the completed journal only after your independent attempt.

Explain it, then change it yourself

The transferable skill is choosing state ownership and a useful observable checkpoint before adding infrastructure. This workshop covers common patterns in the practice bank; company prompts can add API, image, paging, build, or media requirements that need their own work.

Fundamentals: AF01 · AF03 · AF04 · AF05 · AF06 · AF09 · AF10 · AF11 · AF12 · AF17 · AF18
Practice problems: EP01 · EP02 · EP03 · EP04 · EP05 · EP06 · EP08 · EP09 · EP10 · EP11

Use the same patterns in the other problems

After completing this tutorial, close the reference. Pick a practice problem and implement it from its brief. Explain which workshop pattern you are reusing and which domain rule is different.

ProblemApply what you learned
EP01Replace nights with messages. Preserve IDs, pending status, an append-only outbox, and lifecycle-aware StateFlow collection. Add prompt-specific ordering and input behavior.
EP02Use the list/detail screen split and latest search test. Replace the demo repository with the public API boundary and map DTOs to domain data.
EP03Use immutable ride rows, keyed lists, explicit loading and error, and click events. Add ride-specific service rules.
EP04Combine current input values with local rows. Use flatMapLatest for cancellable remote searches and keep ETA updates separate from durable effects.
EP05Persist favorite or saved-place IDs. Treat query and draft as restored inputs, while saved places belong in Room.
EP06Use lifecycle and coroutine ownership to diagnose the provided source. Gradle flavors and build failures need the separate build-system lessons.
EP07Apply typed models and pure unit-tested transformations. The infection problem is an algorithm exercise and does not require this UI or storage stack.
EP08Reuse the sample list, detail, favorite filter, and Room observable source. Keep sleep data fake unless an API is provided.
EP09Reuse operation state and cancellation handling, but define device command ordering and observed state separately. A real hardware control cannot treat a local receipt as physical confirmation.
EP10Reuse keyed list identity and phase reasoning. Player ownership, visibility, and media resource release need the feed-specific requirements.
EP11Use cancellable searches for changing location, stable venue IDs for favorites, and an offline local source. Add location lifecycle and permissions only when required.

Compare with your path's final reference

Each download is a complete project with the shared storage and sync code plus its selected screen/ViewModel implementation.

On October 10 after simplifying screen state, both actual WS04 ViewModels compiled with Kotlin CLI, cached lifecycle 2.11.0 and coroutines 1.9.0. Both final logic variants compiled after removing Hilt annotations only in temporary copies. All four runs exited 0 without diagnostics. The shared shape is a plain JournalUiState data class containing a Loading/Ready/Failure LoadState enum, non-null Journal data, errorMessage, UserInput, separate Operation and offline status. Compose uses simple if/else checks. This verifies Kotlin logic, not pinned lifecycle 2.9.0, Compose, injection or generated Room/Hilt code. IDs remain plain strings and note UUIDs persist as retry keys. Current tests could not rerun because JUnit, Hamcrest and coroutines-test jars are missing. Earlier successful tests are historical. The existing Gradle gate fails before compilation because FileLockContentionHandler cannot create a socket: Operation not permitted. Full APK and device paths remain unverified. No new tests were added.

Primary references