Real World Example

Grocery App: Products Screen End to End

Putting it together, this is how a real grocery app (BigBasket, Blinkit) builds its product list: Retrofit fetches, Room caches, the repository exposes a Flow, the ViewModel turns it into StateFlow<UiState>, and Compose renders it.

ShopApi (Retrofit) ──refresh()──► ProductDao (Room) ──Flow<List<Entity>>──► ProductRepository
                                                                              │ map to domain
ProductsScreen (Compose) ◄── collectAsStateWithLifecycle ◄── ProductsViewModel.uiState (stateIn)

ViewModel

data class ProductsUi(
    val products: List<Product> = emptyList(),
    val query: String = "",
    val isRefreshing: Boolean = false,
    val error: String? = null,
)

class GroceryViewModel(private val repo: ProductRepository) : ViewModel() {
    private val query = MutableStateFlow("")
    private val refreshing = MutableStateFlow(false)
    private val error = MutableStateFlow<String?>(null)

    val uiState: StateFlow<ProductsUi> = combine(
        repo.observeProducts(), query, refreshing, error,
    ) { products, q, isRefreshing, err ->
        ProductsUi(
            products = products.filter { it.name.contains(q, ignoreCase = true) },
            query = q,
            isRefreshing = isRefreshing,
            error = err,
        )
    }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), ProductsUi())

    init { refresh() }

    fun onQueryChange(q: String) { query.value = q }

    fun refresh() = viewModelScope.launch {
        refreshing.value = true
        error.value = repo.refresh().exceptionOrNull()?.let { "Showing saved prices. Pull to retry." }
        refreshing.value = false
    }
}

Screen

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun GroceryScreen(viewModel: GroceryViewModel = viewModel(factory = GroceryViewModel.Factory)) {
    val ui by viewModel.uiState.collectAsStateWithLifecycle()

    Scaffold(topBar = { TopAppBar(title = { Text("Groceries") }) }) { padding ->
        PullToRefreshBox(
            isRefreshing = ui.isRefreshing,
            onRefresh = viewModel::refresh,
            modifier = Modifier.padding(padding),
        ) {
            LazyColumn(contentPadding = PaddingValues(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
                item { SearchBar(ui.query, viewModel::onQueryChange) }
                ui.error?.let { msg -> item { Text(msg, color = MaterialTheme.colorScheme.error) } }
                items(ui.products, key = { it.id }) { p -> ProductCard(p, onAdd = { /* add to cart */ }) }
            }
        }
    }
}

Exercise: Add an "Out of stock" label: products with stock == 0 should show a disabled "Add" button and the text "Out of stock" in the error colour. Which file(s) do you change?

Show answer
// Only the UI: ProductCard. The data (stock) is already in Product.
FilledTonalButton(onClick = onAdd, enabled = product.stock > 0) { Text("Add") }
if (product.stock == 0) {
    Text("Out of stock", color = MaterialTheme.colorScheme.error,
         style = MaterialTheme.typography.labelMedium)
}