Careeroza
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)
}