Product app study sheet
One product app, built in interview order: setup, model, a fake repository, the ViewModel, the screens, the network, then tests. One flat list in the ViewModel is the source of truth; load, add and delete replace it, date headers are derived from it, and the detail screen looks products up by id. Follow-ups and gotchas come last.
Prints on three letter pages, two columns each. "Say this" and "What was checked" are on this page only.
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>
suspend fun search(query: String): List<Product> // follow-up: search
suspend fun getPage(skip: Int, limit: Int): List<Product> // follow-up: paging
}
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
}
override suspend fun search(query: String) = getProducts().filter { it.title.contains(query, ignoreCase = true) }
override suspend fun getPage(skip: Int, limit: Int) = getProducts().drop(skip).take(limit)
}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
@GET("products/search")
suspend fun search(@Query("q") query: String): ProductsResponse
@GET("products")
suspend fun page(@Query("limit") limit: Int, @Query("skip") skip: 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() = call { api.products(limit = 20) }
override suspend fun search(query: String) = call { api.search(query) }
override suspend fun getPage(skip: Int, limit: Int) = call { api.page(limit, skip) }
// 404/500 -> IOException, so the ViewModel's one catch handles every failure.
private suspend fun call(request: suspend () -> ProductsResponse): List<Product> = try {
request().products.map { it.toDomain() }
} catch (e: HttpException) {
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, records searches.
class ScriptedProductRepository(results: List<Result<List<Product>>>) : ProductRepository {
private val results = ArrayDeque(results)
var calls = 0
val queries = mutableListOf<String>()
override suspend fun getProducts(): List<Product> {
calls++
delay(100)
return results.removeFirst().getOrThrow()
}
override suspend fun search(query: String) = emptyList<Product>().also { queries += query }
override suspend fun getPage(skip: Int, limit: Int) = emptyList<Product>()
}
@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
}
@Test fun httpErrorBecomesIOException() = runTest {
val api = object : ProductApi {
override suspend fun products(limit: Int): ProductsResponse =
throw HttpException(Response.error<ProductsResponse>(404, "".toResponseBody()))
override suspend fun search(query: String) = ProductsResponse(emptyList())
override suspend fun page(limit: Int, skip: Int) = ProductsResponse(emptyList())
}
val error = runCatching { NetworkProductRepository(api).getProducts() }.exceptionOrNull()
assertTrue(error is IOException)
assertEquals("HTTP 404", error?.message)
}
@Test fun searchWaitsForTypingToPause() = runTest {
val repo = loads(emptyList())
val vm = ProductViewModel(repo)
backgroundScope.launch { vm.searchResults.collect {} } // WhileSubscribed needs a collector
vm.onQueryChange("p"); advanceTimeBy(100)
vm.onQueryChange("ph"); advanceTimeBy(100)
vm.onQueryChange("phone")
advanceUntilIdle()
assertEquals(listOf("phone"), repo.queries) // one request, for the last text
}
}9Follow-up: search with debounce
Why: goes in ProductViewModel. debounce waits for typing to pause; mapLatest cancels the old request, so stale results never win.
Kotlin
private val query = MutableStateFlow("")
@OptIn(FlowPreview::class, ExperimentalCoroutinesApi::class)
val searchResults: StateFlow<List<Product>> = query
.debounce(300) // wait until typing pauses
.distinctUntilChanged()
.mapLatest { q -> // a new query cancels the old search: no stale results
if (q.isBlank()) emptyList()
else try { repository.search(q) } catch (e: IOException) { emptyList() }
}
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
fun onQueryChange(text: String) { query.value = text }10Follow-up: paging
Why: the guard stops double loads and endReached stops at the end. derivedStateOf avoids recomposing on every scroll pixel; keying on the list size retries when a page doesn't fill the screen.
Kotlin
fun loadNextPage() {
val current = _state.value
if (current.isLoading || current.endReached) return
_state.update { it.copy(isLoading = true) } // set before launch: blocks a second call
viewModelScope.launch {
try {
val page = repository.getPage(skip = current.products.size, limit = 20)
_state.update {
it.copy(isLoading = false, products = it.products + page, endReached = page.size < 20)
}
} catch (e: IOException) {
_state.update { it.copy(isLoading = false) }
}
}
}Kotlin
val listState = rememberLazyListState()
val nearEnd by remember {
derivedStateOf { // recomposes only when true/false flips, not on every scroll pixel
val last = listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0
last >= listState.layoutInfo.totalItemsCount - 5
}
}
LaunchedEffect(nearEnd, state.products.size) { if (nearEnd) onLoadMore() }11Follow-up: group off the main thread
Why: only when grouping a large list makes the UI stutter. update { } can't suspend, so derive the map as a flow instead; ProductUiState then drops grouped. Dispatchers.Default is for CPU work, IO for blocking calls.
Kotlin
// Declare this after `state` in the ViewModel: properties initialize top to bottom.
val grouped: StateFlow<Map<String, List<Product>>> = state
.map { it.products }
.distinctUntilChanged() // regroup only when the list changes, not on isLoading
.map { groupByDate(it) }
.flowOn(Dispatchers.Default) // the grouping runs off the main thread
.stateIn(viewModelScope, SharingStarted.Eagerly, emptyMap())12Gotcha: 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.asConverterFactoryNot java.io.Serializable, not okio.IOException. animateItem() needs Compose Foundation 1.7 or later.
13Gotcha: 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
14Say 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."
15What was checked
On 2026-10-12, every code block on this page was compiled in a copy of David's EP02 project: AGP 9.4.1, Kotlin 2.2.10, Compose BOM 2026.02.01 (Foundation 1.10.4, Material 3 1.4.0), Navigation Compose 2.9.8, Lifecycle 2.9.4, Retrofit 3.0.0, kotlinx-serialization-json 1.8.1 and kotlinx-coroutines-test 1.9.0, with no errors or warnings. The setup lines were used as written, with the catalog's lifecycle version named lifecycle. Sections 4 and 9 are one ProductViewModel; section 10 is ProductPagingViewModel; section 11 compiled in its own ViewModel.
The 6 tests in section 8 passed. In an earlier build of this code, deliberate breaks (no double-tap guard, no debounce, add without regrouping, error not cleared on Retry) each made exactly the matching test fail; those tests are unchanged.
On a Pixel emulator against live DummyJSON: products loaded under date headers, newest first, with two products sharing one date under one header; tapping a product opened its detail; Delete removed the row and its header; starting offline showed "Couldn't load", and Retry loaded the list once back online. Search was checked by its test, not on the emulator. Paging compiled, and the same logic ran on the emulator in an earlier version with different names, not in this one. The animateItem() animation was not checked by eye.