Android · Study sheet

Product app core sheet

The two-page core of the Product app study sheet: what a 30-minute build most likely needs. Setup, model, a fake repository, the ViewModel, the screens, the network and tests, then gotchas. Search, paging and off-main grouping are on the full sheet.

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

1Setup: Gradle

Why: read [versions] first: each version.ref must match a name there (lifecycle, or lifecycleRuntimeKtx in older templates). viewmodel-compose gives viewModel { }, runtime-compose gives collectAsStateWithLifecycle.

TOML

[versions]
coroutines = "1.9.0" # add only if missing; lifecycle and kotlin are already there
retrofit = "3.0.0"
kotlinxSerialization = "1.8.1" # Kotlin 2.2: 1.9.0, 2.3: 1.10.0
navigationCompose = "2.9.8"

[libraries]
androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycle" }
androidx-lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycle" }
kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "coroutines" }
retrofit-core = { group = "com.squareup.retrofit2", name = "retrofit", version.ref = "retrofit" }
retrofit-kotlinx-serialization = { group = "com.squareup.retrofit2", name = "converter-kotlinx-serialization", version.ref = "retrofit" }
kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinxSerialization" }
androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigationCompose" }

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

Kotlin

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

// app/build.gradle.kts
plugins {
    alias(libs.plugins.kotlin.serialization)
}
dependencies {
    implementation(libs.androidx.lifecycle.viewmodel.compose)
    implementation(libs.androidx.lifecycle.runtime.compose)
    implementation(libs.retrofit.core)
    implementation(libs.retrofit.kotlinx.serialization)
    implementation(libs.kotlinx.serialization.json)
    implementation(libs.androidx.navigation.compose)
    testImplementation(libs.kotlinx.coroutines.test)
}

2Model and UI state

Why: decide what the screen can show first. grouped is derived from products and only ever set in the same update. ISO date strings sort correctly as text.

Kotlin

// ISO dates ("2026-10-12") sort correctly as plain text.
data class Product(val id: Int, val title: String, val price: Double, val date: String)

data class ProductUiState(
    val isLoading: Boolean = false,
    val products: List<Product> = emptyList(),             // source of truth
    val grouped: Map<String, List<Product>> = emptyMap(),  // derived, never set on its own
    val error: String? = null,
)

// Sort first: groupBy keeps insertion order, so rows and headers come out newest first.
fun groupByDate(products: List<Product>): Map<String, List<Product>> =
    products.sortedByDescending { it.date }.groupBy { it.date }

3Repository: interface and fake

Why: the fake gets the app running in minutes with no network risk; the interface lets the ViewModel take the fake, the network version or a test double.

Kotlin

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

val sampleProducts = listOf(
    Product(1, "Lamp", 24.5, "2026-10-11"),
    Product(2, "Blanket", 39.0, "2026-10-12"),
    Product(3, "Pillow", 19.0, "2026-10-12"),
)

class FakeProductRepository(
    private val products: List<Product> = sampleProducts,
    private var fail: Boolean = false,
) : ProductRepository {
    override suspend fun getProducts(): List<Product> {
        delay(500) // suspends, doesn't block; lets you see the spinner
        if (fail) { fail = false; throw IOException("Offline") }
        return products
    }
}

4ViewModel: load, add, delete

Why: the only writer of state. + and filterNot build new lists, so StateFlow emits. One load() serves the first load and Retry. Race: an add during the first load is lost when the load lands.

Kotlin

class ProductViewModel(private val repository: ProductRepository) : ViewModel() {
    private val _state = MutableStateFlow(ProductUiState())
    val state: StateFlow<ProductUiState> = _state.asStateFlow()
    private var loadJob: Job? = null

    init { load() }

    fun load() { // first load and Retry
        if (loadJob?.isActive == true) return // ignore double taps
        loadJob = viewModelScope.launch {
            _state.update { it.copy(isLoading = true, error = null) }
            try { // try INSIDE launch: that's where the call throws
                val loaded = repository.getProducts()
                _state.update { it.copy(isLoading = false, products = loaded, grouped = groupByDate(loaded)) }
            } catch (e: IOException) {
                _state.update { it.copy(isLoading = false, error = "Couldn't load") }
            }
        }
    }

    fun add(product: Product) = _state.update { s ->
        val list = s.products + product                          // new list, never mutate in place
        s.copy(products = list, grouped = groupByDate(list))     // same update, so the two can't drift
    }

    fun delete(id: Int) = _state.update { s ->
        val list = s.products.filterNot { it.id == id }
        s.copy(products = list, grouped = groupByDate(list))
    }
}

5UI: list with date headers

Why: state down, events up as lambdas. stickyHeader pins each date; animateItem() animates add and delete. items(list) needs its own import.

Kotlin

@Composable
fun ProductListScreen(
    state: ProductUiState,
    onRetry: () -> Unit,
    onOpen: (Int) -> Unit,
    onDelete: (Int) -> Unit,
) {
    Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
        when { // order matters: keep showing data during a reload
            state.products.isNotEmpty() -> LazyColumn(Modifier.fillMaxSize()) {
                state.grouped.forEach { (date, rows) -> // each entry: header, then its rows
                    stickyHeader(key = date) {
                        Text(
                            date,
                            Modifier.fillMaxWidth().background(MaterialTheme.colorScheme.surfaceVariant).padding(8.dp),
                        )
                    }
                    items(rows, key = { it.id }) { product -> // String and Int keys never collide
                        ProductRow(product, onOpen, onDelete, Modifier.animateItem())
                    }
                }
            }
            state.isLoading -> CircularProgressIndicator()
            state.error != null -> Column(horizontalAlignment = Alignment.CenterHorizontally) {
                Text(state.error) // param, not `by` delegate, so smart cast works
                Button(onClick = onRetry) { Text("Retry") }
            }
            else -> Text("No products yet")
        }
    }
}

@Composable
fun ProductRow(product: Product, onOpen: (Int) -> Unit, onDelete: (Int) -> Unit, modifier: Modifier = Modifier) {
    ListItem(
        headlineContent = { Text(product.title) },
        supportingContent = { Text("$%.2f".format(product.price)) },
        trailingContent = { TextButton(onClick = { onDelete(product.id) }) { Text("Delete") } },
        modifier = modifier.clickable { onOpen(product.id) },
    )
}

6UI: Activity, navigation, detail

Why: the ViewModel above the NavHost is shared, so the detail sees adds and deletes. Pass the id, not the object: it survives process death and never goes stale.

Kotlin

Scaffold(topBar = { TopAppBar(title = { Text("Products") }) }) { innerPadding ->
    ProductApp(Modifier.padding(innerPadding)) // use it or content hides under bars
}

Kotlin

@Serializable object ProductListRoute
@Serializable data class ProductDetailRoute(val id: Int) // pass the id, not the object

@Composable
fun ProductApp(modifier: Modifier = Modifier) {
    val nav = rememberNavController()
    // Above the NavHost, so the list and the detail share one ViewModel.
    val viewModel = viewModel { ProductViewModel(NetworkProductRepository(productApi)) } // or FakeProductRepository()
    val state by viewModel.state.collectAsStateWithLifecycle()

    NavHost(nav, startDestination = ProductListRoute, modifier = modifier) {
        composable<ProductListRoute> {
            ProductListScreen(
                state,
                onRetry = viewModel::load,
                onOpen = { id -> nav.navigate(ProductDetailRoute(id)) },
                onDelete = viewModel::delete,
            )
        }
        composable<ProductDetailRoute> { entry ->
            val id = entry.toRoute<ProductDetailRoute>().id
            ProductDetailScreen(state.products.find { it.id == id }) // look it up where add/delete write
        }
    }
}

// When the detail screen has its own ViewModel, it reads the same id:
class ProductDetailViewModel(savedStateHandle: SavedStateHandle) : ViewModel() {
    val id = savedStateHandle.toRoute<ProductDetailRoute>().id // restored after process death
}

Kotlin

@Composable
fun ProductDetailScreen(product: Product?) {
    if (product == null) {
        Text("Product not found")
        return
    }
    Column(Modifier.padding(16.dp)) {
        Text(product.title, style = MaterialTheme.typography.headlineSmall)
        Text("$%.2f".format(product.price))
        Text(product.date)
    }
}

7Network: DTO, Retrofit, repository

Why: DTO types must match the JSON. DummyJSON has no plain date, so the mapper takes it from meta.createdAt. Convert HttpException so one catch handles every failure; never catch Exception (it swallows CancellationException).

Kotlin

@Serializable data class ProductsResponse(val products: List<ProductDto>)
@Serializable data class ProductDto(val id: Int, val title: String, val price: Double, val meta: MetaDto)
@Serializable data class MetaDto(val createdAt: String) // "2025-10-09T14:47:01.588Z"

fun ProductDto.toDomain() = Product(id, title, price, date = meta.createdAt.take(10))

Kotlin

interface ProductApi {
    @GET("products")
    suspend fun products(@Query("limit") limit: Int): ProductsResponse
}

private val json = Json { ignoreUnknownKeys = true }

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

Kotlin

class NetworkProductRepository(private val api: ProductApi) : ProductRepository {
    override suspend fun getProducts(): List<Product> = try {
        api.products(limit = 20).products.map { it.toDomain() }
    } catch (e: HttpException) { // 404/500 -> IOException, so the ViewModel's one catch handles it
        throw IOException("HTTP ${e.code()}", e)
    }
}

8Test the ViewModel

Why: viewModelScope runs on Main, so swap it. Virtual time: runCurrent() stops at the first delay, advanceUntilIdle() runs everything, advanceTimeBy() steps the clock.

Kotlin

// Test-only fake: scripted getProducts() results in order, counts calls.
class ScriptedProductRepository(results: List<Result<List<Product>>>) : ProductRepository {
    private val results = ArrayDeque(results)
    var calls = 0
    override suspend fun getProducts(): List<Product> {
        calls++
        delay(100)
        return results.removeFirst().getOrThrow()
    }
}

@OptIn(ExperimentalCoroutinesApi::class)
class ProductViewModelTest {
    private val lamp = Product(1, "Lamp", 24.5, "2026-10-11")
    private val blanket = Product(2, "Blanket", 39.0, "2026-10-12")
    private val pillow = Product(3, "Pillow", 19.0, "2026-10-12")
    private fun loads(vararg lists: List<Product>) = ScriptedProductRepository(lists.map { Result.success(it) })
    private val offline = Result.failure<List<Product>>(IOException("Offline"))

    @Before fun setUp() { Dispatchers.setMain(StandardTestDispatcher()) } // viewModelScope = Main
    @After fun tearDown() { Dispatchers.resetMain() }

    @Test fun loadingThenGroupedData() = runTest {
        val vm = ProductViewModel(loads(listOf(lamp, blanket, pillow)))
        runCurrent() // stops at the delay: request in flight
        assertTrue(vm.state.value.isLoading)
        advanceUntilIdle() // virtual time: the delay is instant
        assertFalse(vm.state.value.isLoading)
        assertEquals(listOf("2026-10-12", "2026-10-11"), vm.state.value.grouped.keys.toList()) // newest first
        assertEquals(listOf(blanket, pillow), vm.state.value.grouped["2026-10-12"])
    }

    @Test fun errorThenRetryClearsError() = runTest {
        val vm = ProductViewModel(ScriptedProductRepository(listOf(offline, Result.success(listOf(lamp)))))
        advanceUntilIdle()
        assertEquals("Couldn't load", vm.state.value.error)
        vm.load()
        advanceUntilIdle()
        assertNull(vm.state.value.error)
        assertEquals(listOf(lamp), vm.state.value.products)
    }

    @Test fun secondTapWhileLoadingIsIgnored() = runTest {
        val repo = loads(listOf(lamp))
        val vm = ProductViewModel(repo)
        runCurrent()
        vm.load() // first request still in flight
        advanceUntilIdle()
        assertEquals(1, repo.calls)
    }

    @Test fun addAndDeleteUpdateListAndHeaders() = runTest {
        val vm = ProductViewModel(loads(listOf(lamp)))
        advanceUntilIdle() // finish the init load first, or it overwrites the add
        vm.add(blanket)
        assertEquals(listOf(blanket), vm.state.value.grouped["2026-10-12"])
        vm.delete(lamp.id)
        assertEquals(listOf(blanket), vm.state.value.products)
        assertNull(vm.state.value.grouped["2026-10-11"]) // last row gone, so the header is gone
    }

}

9Gotcha: imports auto-import misses

Kotlin

import androidx.compose.foundation.lazy.items
import androidx.compose.runtime.getValue
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.compose.composable
import androidx.navigation.toRoute
import kotlinx.serialization.Serializable
import java.io.IOException
import okhttp3.MediaType.Companion.toMediaType
import retrofit2.converter.kotlinx.serialization.asConverterFactory

Not java.io.Serializable, not okio.IOException. animateItem() needs Compose Foundation 1.7 or later.

10Gotcha: error, then fix

  • List<T> but Int was expected: Import androidx.compose.foundation.lazy.items
  • Smart cast impossible: delegated property: state.error.orEmpty(), or copy it into a local val
  • Undefined version reference: Use a name that exists in [versions]
  • Expected quotation mark at $.x.id: DTO type must match the JSON type
  • MissingFieldException: Give the field a default value
  • Encountered an unknown key: Json { ignoreUnknownKeys = true }
  • Serializer has not been found: @Serializable on every nested class; plugin applied
  • This material API is experimental: @OptIn(ExperimentalMaterial3Api::class)
  • Infinite height constraints: No LazyColumn inside verticalScroll

11Say this

  • Architecture: "State flows down and events flow up. The ViewModel is the only writer, and every change is a new immutable list, so the UI can't drift from the state."
  • Hot vs cold: "StateFlow is hot and holds the latest value, so a rotated screen re-collects without reloading. A cold Flow runs once per collector; stateIn shares it, and WhileSubscribed(5_000) outlives a rotation."
  • Grouping: "I recompute the grouped map in the same update that changes the list, and groupByDate is one pure function I test directly. A background flow only if the list is big enough to jank."
  • Routes: "I pass the id because it's small enough to survive process death in the saved state, and it stays correct. An object copy would be a stale snapshot."
  • DI: "Constructor injection lets me pass a fake in tests. Manual DI here; Hilt when the app grows."
  • Testing: "Logic lives in the ViewModel, so most coverage is fast JVM tests; instrumented tests only for what needs a running UI."
  • Process death: "The ViewModel is lost. rememberSaveable and SavedStateHandle survive, and real data goes in Room."

12What was checked

On 2026-10-12, every code block on this page was compiled in a copy of David's EP02 project with the same versions as the full study sheet (AGP 9.4.1, Kotlin 2.2.10, Compose BOM 2026.02.01, Navigation Compose 2.9.8, Lifecycle 2.9.4, Retrofit 3.0.0, coroutines-test 1.9.0), with no errors or warnings. This is the full sheet's code with search, paging and off-main grouping taken out. The 4 tests passed. On a Pixel emulator against live DummyJSON, products loaded under date headers, tapping one opened its detail, and starting offline showed "Couldn't load" with Retry. Delete and Retry were checked on the full sheet's version, which shares this ViewModel and these screens.