Android · Follow-up practice

EP02 follow-up: add and edit products

A follow-up to the EP02 catalog: let the user add and edit products through ViewModel actions, with no network. Write it yourself, then open the answers. The full solution is at the bottom.

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

Finish EP02 first: the list loads, a row opens its detail screen, Back returns, and offline shows Retry. Then take this follow-up. Write it yourself first, then compare your answers with the collapsed sections. The full solution is at the bottom, RB13 to RB18.

19 sections
RB01The follow-up

Interviewer: "Your catalog works. Now let the user add a product and edit an existing one. There is no write API, so the changes only live in the app. The list and the detail screen should show the change as soon as the user saves."

Say back what you heard before typing: two new user actions, add and edit, handled by the ViewModel instead of the network, with one place that owns the list.

RB02Requirements and done
  • An Add product button on the list opens a form screen with title, price and description.
  • The detail screen has an Edit button that opens the same form, filled in with that product.
  • Save validates the input: the title is not blank and the price is a number of zero or more. Save is disabled until it is valid.
  • Saving goes back one screen. The list and the detail screen show the change immediately.
  • The screens never change the list themselves. They send an action to the ViewModel, and only the ViewModel updates state.
  • No network call for add or edit.

Done when

  • Add a product, and it appears in the list with its price.
  • Open a product, tap Edit, change its title and price, save, and the detail screen shows the new values. Back, and the list row shows them too.
  • Type an invalid price like abc, and Save stays disabled.
  • Rotate the device halfway through typing in the form, and nothing you typed is lost.
RB03Talk through it first

Before writing code, say the plan in two or three sentences. For example: "I'll model add and edit as actions the screen sends to the ViewModel. The ViewModel applies them to the product list in its state, so every screen that reads that state updates. The form keeps its own text until Save, then sends one action and pops back."

Then name the decision you are making on purpose: which ViewModel the form talks to (RB12), and what happens to edits when the list reloads (RB06).

RB04Hints

Open this only when you are stuck, and read one hint at a time.

  • Actions as types. A sealed interface with one class per user action (Add with its fields, Edit with an id and its fields) and one function on the ViewModel that handles them with when.
  • No coroutine needed. Add and edit don't wait on anything, so the ViewModel can update the StateFlow directly with update { }.
  • Editing an immutable list. Build a new list where the matching product is replaced with copy(...) and the rest are kept as they are. copy also keeps fields the form doesn't touch, like reviews and dimensions.
  • Which ViewModel? The form must change the same list the list screen shows. Think about where the ViewModel is created and which screens get the same instance.
  • One form, two jobs. One form route can take an optional product id: no id means add, an id means edit. Two separate routes also work.
  • Form state. Keep the text fields in the form until Save, with rememberSaveable so rotation keeps them. The price field is a String: convert it with toDoubleOrNull() to validate.
  • After Save. Send the action, then navController.popBackStack().
  • Navigation setup. Use the TP17 catalog lines in TP00. navigation-compose 2.9.8 also builds with this project's AGP 9.4.1 and Kotlin 2.2.10.
RB05Routing refresher: list to detail

TP17 has the full pattern with sleep nights. The same idea for products:

  • Routes are @Serializable classes. An object for the list (it has no arguments) and a data class for the detail that carries only the product id.
  • The NavHost maps each route to a screen. composable<YourListRoute> { ... } shows the list, and composable<YourDetailRoute> { ... } shows the detail.
  • The row doesn't navigate. It calls an onProductClick(id) lambda. The NavHost decides what that means: navController.navigate(YourDetailRoute(id)).
  • The detail reads the id back from the back-stack entry with toRoute<YourDetailRoute>().id, or from a SavedStateHandle in its own ViewModel, as TP17 does.
  • Back is free. The system Back pops the detail off the back stack.

For this follow-up, think about where the detail gets its product. A detail screen that fetches GET products/{id} from the network would not show the user's local edit. Reading it by id from the list the ViewModel owns would.

RB06Follow-up: what happens to edits when the list reloads?

Answer: They are lost. load() replaces the product list with what the server returns, and the server never saw the edits. Today that only happens on Retry after an error, but pull-to-refresh would trigger it on every swipe.

Two ways to fix it. Keep the local changes apart from the server list, as added products plus edits by id, and merge them in whenever server data arrives. Or move ownership down: the repository keeps the list (in memory, or in Room for real persistence), add and edit go through the repository, and the ViewModel only exposes it. That gives one source of truth.

Say this: "In production the repository owns the data and the ViewModel only exposes it. For the interview I keep it in the ViewModel and call out that a reload drops local edits."

RB07Follow-up: what survives rotation and process death?

Rotation: The ViewModel survives a configuration change, so the list and its edits stay. Form fields held in rememberSaveable survive too. Fields held in plain remember would reset.

Process death: The ViewModel is gone. When the user returns, the back stack and rememberSaveable fields are restored from the saved Bundle, but the ViewModel starts over and reloads from the network, so the edits are lost. Keeping them needs real storage: Room or DataStore for data, SavedStateHandle only for small UI state.

RB08Follow-up: why doesn't the form write to the ViewModel on every keystroke?

Answer: What the user is typing is a draft, not committed data. If every keystroke changed the shared list, the list and detail screens would change while the user typed, and pressing Back to cancel would have nothing to throw away. The form keeps a draft, and Save sends one action.

If the form grows (async validation, autosave, a draft that must survive process death), give it its own ViewModel that holds the draft and still sends one action on Save.

RB09Follow-up: how do you give a new product an id?

Answer: The highest existing id plus one works for one session, but it can collide with an id the server returns later. Because LazyColumn keys must be unique, a duplicate id crashes the list. Safer options: negative ids or a UUID for local-only items, then replace them with the server's id when the item is synced.

RB10Follow-up: what if there were a real write API?

Answer: Add becomes a network call, so it moves into viewModelScope.launch. Two styles:

  • Pessimistic: show a saving state, wait for the server, then add the returned product. Simple and always correct, but slower to feel.
  • Optimistic: add the product right away with a temporary id and a pending flag, call the API, then replace it with the server's version on success, or remove it and show an error on failure.

DummyJSON has POST products/add, but it only simulates the write: it returns the new product without saving it.

RB11Follow-up: how would you test this?

Answer: The actions are plain state changes, so the ViewModel is easy to test without any UI. Give it a fake repository, let the initial load finish, send onAction(Add(...)), and assert that state.value.products ends with the new product. Do the same for Edit, and for an Edit with an id that doesn't exist (the list should not change). Coroutine tests need Dispatchers.setMain with a test dispatcher, because viewModelScope runs on Main.

RB12Follow-up: one shared ViewModel or one per screen?

A shared ViewModel is the simplest answer here: create it once above the NavHost (scoped to the Activity) and pass it to every screen. One list, and an edit shows everywhere at once. The cost: it grows into one large class that every screen depends on, and it lives as long as the Activity.

One ViewModel per screen scales better, but then the screens need a shared source of truth below them, usually the repository (RB06). Another option is a ViewModel scoped to a nested navigation graph, so only the screens in that flow share it.

Say this: "For three screens and a 30-minute follow-up I'd share one ViewModel. As the app grows I'd move the list into the repository and give each screen its own ViewModel."

RB13Solution: what changes

The solution covers the EP02 gaps and the follow-up together. Files that are not listed stay as they are, including the DTOs, mappers, Product models, productApi and MockRepository.

FileChange
gradle/libs.versions.toml and app/build.gradle.ktsAdd navigation-compose (RB14)
Repository.ktReplace NetworkRepository and add one import (RB15)
ViewModel.ktReplace the whole file (RB16)
ProductScreens.ktNew file: list, detail and form screens (RB17)
MainActivity.ktReplace the whole file: routes and the NavHost (RB18)

What it fixes from the EP02 build:

  • Offline showed a blank screen. The repository now lets the IOException through, and the ViewModel catches it inside launch.
  • No Retry and no empty state. The list screen has an error branch with Retry and a "No products yet" branch.
  • No detail screen. Routes, a NavHost, and a detail screen with an Edit button.
  • Smaller things. collectAsStateWithLifecycle instead of collectAsState, java.io.IOException instead of okio.IOException, a private repository, and the unused Greeting and uiStateIndicator are gone.
RB14Solution: navigation setup

TOML

# gradle/libs.versions.toml
[versions]
navigationCompose = "2.9.8"

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

Kotlin

// app/build.gradle.kts, inside dependencies { }
implementation(libs.androidx.navigation.compose)

The serialization plugin and kotlinx-serialization-json are already in the project, and the routes need both.

RB15Solution: Repository.kt

Replace NetworkRepository with this, and add import retrofit2.HttpException at the top.

Kotlin

class NetworkRepository(val api: ProductApi) : Repository {
    override suspend fun getProducts(): List<Product> = try {
        api.products(limit = 20).products.map { it.toDomain() }
    } catch (e: HttpException) {
        // A 404 or 500 is not an IOException. Convert it so the ViewModel's
        // single catch (e: IOException) still shows Retry.
        throw IOException("HTTP ${e.code()}", e)
    }
}

It no longer catches Exception. An IOException (offline, timeout) passes through to the ViewModel, and an HTTP error becomes one. Catching Exception also swallowed CancellationException, which must be allowed through.

RB16Solution: ViewModel.kt

Kotlin

package com.shipfast.robinhood

import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import java.io.IOException
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch

data class RobinhoodUiState(
    val isLoading: Boolean = false,
    val products: List<Product> = emptyList(),
    val error: String? = null,
)

// Everything the user can do to the list. The screen sends these; only the ViewModel changes state.
sealed interface ProductAction {
    data class Add(val title: String, val description: String, val price: Double) : ProductAction
    data class Edit(val id: Int, val title: String, val description: String, val price: Double) : ProductAction
}

class RobinhoodViewModel(private val repository: Repository) : ViewModel() {
    private val _state = MutableStateFlow(RobinhoodUiState())
    val state: StateFlow<RobinhoodUiState> = _state.asStateFlow()
    private var loadJob: Job? = null

    init { load() }

    fun load() {
        if (loadJob?.isActive == true) return
        loadJob = viewModelScope.launch {
            _state.update { it.copy(isLoading = true, error = null) }
            try {
                val products = repository.getProducts()
                _state.update { it.copy(isLoading = false, products = products) }
            } catch (e: IOException) {
                _state.update { it.copy(isLoading = false, error = "Couldn't load products") }
            }
        }
    }

    // No network and no coroutine: a UI action is a synchronous state change.
    fun onAction(action: ProductAction) {
        when (action) {
            is ProductAction.Add -> _state.update { state ->
                val product = Product(
                    id = (state.products.maxOfOrNull { it.id } ?: 0) + 1,
                    title = action.title,
                    description = action.description,
                    price = action.price,
                    dimensions = ProductDimension(0.0, 0.0, 0.0),
                    reviews = emptyList(),
                    thumbnail = "",
                )
                state.copy(products = state.products + product)
            }
            is ProductAction.Edit -> _state.update { state ->
                state.copy(products = state.products.map {
                    if (it.id == action.id) {
                        it.copy(title = action.title, description = action.description, price = action.price)
                    } else {
                        it
                    }
                })
            }
        }
    }
}
  • The try is inside launch. The request runs inside the coroutine, so that is where its exception is thrown.
  • load() sets isLoading and clears error inside the coroutine, so Retry clears the old error.
  • onAction is synchronous. update { } applies each change atomically and the StateFlow emits the new state to every screen.
  • Edit uses copy, so reviews, dimensions and images are kept.
RB17Solution: ProductScreens.kt

Kotlin

package com.shipfast.robinhood

import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExtendedFloatingActionButton
import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp

@Composable
fun ProductListScreen(
    state: RobinhoodUiState,
    onRetry: () -> Unit,
    onOpen: (Int) -> Unit,
    onAdd: () -> Unit,
) {
    Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
        when {
            state.products.isNotEmpty() -> LazyColumn(Modifier.fillMaxSize()) {
                items(state.products, key = { it.id }) { product ->
                    ListItem(
                        headlineContent = { Text(product.title) },
                        supportingContent = { Text("$%.2f".format(product.price)) },
                        modifier = Modifier.clickable { onOpen(product.id) },
                    )
                }
            }
            state.isLoading -> CircularProgressIndicator()
            state.error != null -> Column(horizontalAlignment = Alignment.CenterHorizontally) {
                Text(state.error) // a plain parameter, not a delegated property, so this smart-casts
                Button(onClick = onRetry) { Text("Retry") }
            }
            else -> Text("No products yet")
        }
        ExtendedFloatingActionButton(
            onClick = onAdd,
            modifier = Modifier.align(Alignment.BottomEnd).padding(16.dp),
        ) { Text("Add product") }
    }
}

@Composable
fun ProductDetailScreen(product: Product?, onEdit: () -> Unit) {
    if (product == null) {
        Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { Text("Product not found") }
        return
    }
    Column(Modifier.fillMaxSize().padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
        Text(product.title, style = MaterialTheme.typography.headlineSmall)
        Text(
            "$%.2f".format(product.price),
            style = MaterialTheme.typography.titleMedium,
            color = MaterialTheme.colorScheme.primary,
        )
        Text(product.description)
        Button(onClick = onEdit) { Text("Edit") }
    }
}

// product == null means Add; otherwise the fields start from the product being edited.
@Composable
fun ProductFormScreen(product: Product?, onSave: (ProductAction) -> Unit) {
    var title by rememberSaveable { mutableStateOf(product?.title.orEmpty()) }
    var price by rememberSaveable { mutableStateOf(product?.price?.toString().orEmpty()) }
    var description by rememberSaveable { mutableStateOf(product?.description.orEmpty()) }
    val parsedPrice = price.toDoubleOrNull()
    val canSave = title.isNotBlank() && parsedPrice != null && parsedPrice >= 0

    Column(Modifier.fillMaxSize().padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
        Text(if (product == null) "Add product" else "Edit product", style = MaterialTheme.typography.headlineSmall)
        OutlinedTextField(
            value = title,
            onValueChange = { title = it },
            label = { Text("Title") },
            singleLine = true,
            modifier = Modifier.fillMaxWidth(),
        )
        OutlinedTextField(
            value = price,
            onValueChange = { price = it },
            label = { Text("Price") },
            singleLine = true,
            isError = price.isNotEmpty() && parsedPrice == null,
            keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
            modifier = Modifier.fillMaxWidth(),
        )
        OutlinedTextField(
            value = description,
            onValueChange = { description = it },
            label = { Text("Description") },
            modifier = Modifier.fillMaxWidth(),
        )
        Button(
            onClick = {
                val amount = parsedPrice ?: return@Button
                onSave(
                    if (product == null) ProductAction.Add(title.trim(), description.trim(), amount)
                    else ProductAction.Edit(product.id, title.trim(), description.trim(), amount)
                )
            },
            enabled = canSave,
            modifier = Modifier.fillMaxWidth(),
        ) { Text("Save") }
    }
}
  • The screens take plain state and lambdas, not the ViewModel, so the NavHost decides what each click does.
  • Text(state.error) compiles here. state is a function parameter, not a delegated property, so the smart cast works.
  • The form keeps a draft in rememberSaveable and sends one action on Save (RB08).
  • The button is an ExtendedFloatingActionButton with text, so no icons library is needed.
RB18Solution: MainActivity.kt

Kotlin

package com.shipfast.robinhood

import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Scaffold
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
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import androidx.navigation.toRoute
import com.shipfast.robinhood.ui.theme.RobinhoodTheme
import kotlinx.serialization.Serializable

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        enableEdgeToEdge()
        setContent {
            RobinhoodTheme {
                Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
                    RobinhoodApp(Modifier.padding(innerPadding))
                }
            }
        }
    }
}

@Serializable object ProductListRoute
@Serializable data class ProductDetailRoute(val id: Int)
@Serializable data class ProductFormRoute(val id: Int? = null) // null = add a new product

@Composable
fun RobinhoodApp(modifier: Modifier = Modifier) {
    val navController = rememberNavController()
    // Created outside the NavHost, so it is scoped to the Activity and all three screens share it.
    val viewModel: RobinhoodViewModel = viewModel { RobinhoodViewModel(NetworkRepository(productApi)) }
    val state by viewModel.state.collectAsStateWithLifecycle()

    NavHost(navController, startDestination = ProductListRoute, modifier = modifier) {
        composable<ProductListRoute> {
            ProductListScreen(
                state = state,
                onRetry = viewModel::load,
                onOpen = { id -> navController.navigate(ProductDetailRoute(id)) },
                onAdd = { navController.navigate(ProductFormRoute()) },
            )
        }
        composable<ProductDetailRoute> { entry ->
            val id = entry.toRoute<ProductDetailRoute>().id
            ProductDetailScreen(
                product = state.products.find { it.id == id },
                onEdit = { navController.navigate(ProductFormRoute(id)) },
            )
        }
        composable<ProductFormRoute> { entry ->
            val id = entry.toRoute<ProductFormRoute>().id
            ProductFormScreen(
                product = state.products.find { it.id == id },
                onSave = { action ->
                    viewModel.onAction(action)
                    navController.popBackStack()
                },
            )
        }
    }
}
  • One ViewModel for all three screens. It is created above the NavHost, so it is scoped to the Activity (RB12).
  • ProductFormRoute(id = null) means add. The form looks up the product by id, and finds nothing for null.
  • The detail and the form read the product from the shared state, so a saved edit shows on both without a network call (RB05).
  • Save sends the action, then popBackStack() returns to the detail or the list.
RB19What was checked

The follow-up answers (RB06 to RB12) are general Android behavior, and none of their code was compiled on its own.

On 2026-10-12, the solution in RB14 to RB18 was built in a copy of David's EP02 project with its exact setup (AGP 9.4.1, Kotlin 2.2.10, compileSdk 37) plus navigation-compose 2.9.8, with no compiler errors. The code blocks above are copied byte for byte from that build. It was installed on a Pixel emulator under a separate application id, so David's own app was not replaced, and run against live DummyJSON:

  • The list loaded 20 products. Tapping Powder Canister opened its detail, and Edit opened the form filled in with its title, price and description.
  • Typing abc as the price turned the field red and disabled Save.
  • With the title changed to Powder Pro and the price to 16.50, the device was rotated to landscape and back: both fields kept their values. Save returned to the detail, which showed Powder Pro at $16.50, and Back showed the same in the list.
  • Add product with Desk Lamp, 24.5 and a description, then Save: Desk Lamp at $24.50 appeared at the end of the list.
  • With airplane mode on and the app restarted, the screen showed "Couldn't load products" with Retry. With airplane mode off, Retry loaded the list.

Process death and the RB06 reload case were not tested.