Building a real app: shopping list — a step-by-step guide
You've learned the fundamentals of Kotlin and Jetpack Compose, but putting it all together in a production-quality app feels like a leap. We're going to walk through building a real shopping list application—complete with a responsive UI, local persistence, and background synchronization—so you can see how state management, storage, and networking work in concert on actual Android devices.
This isn't a toy example. We'll build something your team could ship: a shopping list that lets users add items, check them off, persist data locally with Room, and sync completed items to a backend service when the device comes online.
UI — LazyColumn with add and check-off actions
The user-facing layer is often where developers stumble. We need a LazyColumn that displays items, lets users type new items, and allows them to toggle completion status. The key is treating each action (add, check-off) as a discrete event that flows to the ViewModel.
Here's the compose structure:
@Composable
fun ShoppingListScreen(viewModel: ShoppingListViewModel = hiltViewModel()) {
val items by viewModel.items.collectAsState(initial = emptyList())
val inputText by viewModel.inputText.collectAsState(initial = "")
Column(modifier = Modifier.fillMaxSize()) {
LazyColumn(
modifier = Modifier
.weight(1f)
.fillMaxWidth()
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
items(items.size, key = { items[it].id }) { index ->
val item = items[index]
ShoppingListItem(
item = item,
onToggle = { viewModel.toggleItem(item.id) },
onDelete = { viewModel.deleteItem(item.id) }
)
}
}
AddItemRow(
text = inputText,
onTextChange = { viewModel.updateInputText(it) },
onAddClick = { viewModel.addItem(inputText) }
)
}
}Notice we use collectAsState to bridge StateFlow to Compose recomposition. Each item is keyed by its ID to preserve state during list reordering.
The individual item composable handles the visual feedback for checked-off items:
@Composable
fun ShoppingListItem(
item: ShoppingItem,
onToggle: () -> Unit,
onDelete: () -> Unit
) {
Row(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(8.dp))
.background(if (item.completed) Color(0xFFE8E8E8) else Color.White)
.padding(12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Row(
modifier = Modifier
.weight(1f)
.clickable(enabled = true, role = Role.Checkbox) { onToggle() },
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Checkbox(
checked = item.completed,
onCheckedChange = { onToggle() }
)
Text(
text = item.name,
style = MaterialTheme.typography.bodyMedium,
textDecoration = if (item.completed) TextDecoration.LineThrough else TextDecoration.None
)
}
IconButton(onClick = onDelete) {
Icon(Icons.Default.Delete, contentDescription = "Delete item")
}
}
}The UI is declarative and stateless. It reflects whatever the ViewModel exposes, nothing more. This makes it testable and predictable.
State — ViewModel with StateFlow
The ViewModel is the bridge between UI and data layers. We use StateFlow because it's hot, replay-enabled, and thread-safe—exactly what we need for state that the UI observes continuously.
@HiltViewModel
class ShoppingListViewModel @Inject constructor(
private val repository: ShoppingListRepository
) : ViewModel() {
private val _inputText = MutableStateFlow("")
val inputText: StateFlow = _inputText.asStateFlow()
val items: StateFlow> = repository.getAllItems()
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5000),
initialValue = emptyList()
)
fun updateInputText(text: String) {
_inputText.value = text
}
fun addItem(name: String) {
if (name.isBlank()) return
viewModelScope.launch {
repository.addItem(ShoppingItem(name = name, completed = false))
_inputText.value = ""
}
}
fun toggleItem(id: Long) {
viewModelScope.launch {
repository.toggleItem(id)
}
}
fun deleteItem(id: Long) {
viewModelScope.launch {
repository.deleteItem(id)
}
}
}Key patterns here:
- Separation of concerns: Input text is a simple mutable flow; items come from the repository as a hot flow.
- WhileSubscribed: Automatically stops collecting from the repository when no UI is observing, saving resources.
- Coroutine safety: All database and network calls launch on
viewModelScope, automatically cancelled when the ViewModel is cleared.
Storage — Room with Flow queries
Room is our local database. The critical insight: queries that return Flow emit fresh data whenever the underlying tables change, so the UI always reflects the latest state.
@Entity(tableName = "shopping_items")
data class ShoppingItem(
@PrimaryKey(autoGenerate = true) val id: Long = 0,
val name: String,
val completed: Boolean = false,
val createdAt: Long = System.currentTimeMillis(),
val synced: Boolean = false
)
@Dao
interface ShoppingItemDao {
@Query("SELECT * FROM shopping_items ORDER BY createdAt DESC")
fun getAllItems(): Flow>
@Query("SELECT * FROM shopping_items WHERE id = :id")
suspend fun getItemById(id: Long): ShoppingItem?
@Insert
suspend fun insert(item: ShoppingItem)
@Update
suspend fun update(item: ShoppingItem)
@Delete
suspend fun delete(item: ShoppingItem)
@Query("SELECT * FROM shopping_items WHERE synced = 0 LIMIT 100")
suspend fun getUnsyncedItems(): List
}The getAllItems() function returns a Flow. Every time an item is inserted, updated, or deleted, Room automatically emits the new list. The UI subscription (via the ViewModel's StateFlow) receives these updates without any polling or manual refresh.
The repository wraps the DAO and adds business logic:
@Singleton
class ShoppingListRepository @Inject constructor(
private val dao: ShoppingItemDao
) {
fun getAllItems(): Flow> = dao.getAllItems()
suspend fun addItem(item: ShoppingItem) {
dao.insert(item.copy(synced = false))
}
suspend fun toggleItem(id: Long) {
val item = dao.getItemById(id) ?: return
dao.update(item.copy(completed = !item.completed, synced = false))
}
suspend fun deleteItem(id: Long) {
val item = dao.getItemById(id) ?: return
dao.delete(item)
}
}Notice we set synced = false on changes. This flag tells the sync layer (WorkManager) which items need to be sent to the backend.
Sync — WorkManager syncs to a backend when online
Background synchronization is where many apps break: they either don't sync at all, or they sync too aggressively and drain batteries. WorkManager handles the scheduling, batching, and retry logic for us.
First, create a Worker that syncs unsynced items:
class SyncShoppingListWorker(
context: Context,
params: WorkerParameters,
private val repository: ShoppingListRepository,
private val apiService: ShoppingListApiService
) : CoroutineWorker(context, params) {
override suspend fun doWork(): Result {
return try {
val unsyncedItems = repository.getUnsyncedItems()
if (unsyncedItems.isEmpty()) {
return Result.success()
}
val response = apiService.syncItems(unsyncedItems)
// Mark items as synced
unsyncedItems.forEach { item ->
repository.markItemSynced(item.id)
}
Result.success()
} catch (e: IOException) {
// Network error; retry with exponential backoff
Result.retry()
} catch (e: Exception) {
// Unexpected error; don't retry
Result.failure()
}
}
}Then schedule the worker to run periodically and on network state change:
@Singleton
class SyncScheduler @Inject constructor(
private val workManager: WorkManager
) {
fun scheduleSync() {
val syncRequest = PeriodicWorkRequestBuilder(
15,
TimeUnit.MINUTES
).setConstraints(
Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.build()
).setBackoffCriteria(
BackoffPolicy.EXPONENTIAL,
15,
TimeUnit.MINUTES
).build()
workManager.enqueueUniquePeriodicWork(
"shopping_list_sync",
ExistingPeriodicWorkPolicy.KEEP,
syncRequest
)
}
}Call scheduleSync() when your app starts (e.g., in a Hilt-injected initializer or in your Application class). WorkManager will:
- Only run when the device has network connectivity.
- Batch multiple runs if the device is offline.
- Automatically retry with exponential backoff if the network request fails.
- Respect device battery state and doze mode.
Common mistakes
We've seen teams ship shopping list features with subtle bugs. Watch for these:
- Forgetting to clear input text after adding an item: Users expect the text field to reset. We do this in the ViewModel by setting
_inputText.value = ""after the add completes. - Querying the database on the main thread: Using
suspend funin the DAO and launching onviewModelScopeensures database calls happen on Dispatchers.IO. - Syncing too frequently: Fifteen-minute periodic syncs are reasonable for a shopping list. One-minute intervals will drain batteries and annoy backend teams. Use WorkManager constraints to avoid syncing when offline.
- Not handling the synced flag correctly: If you update an item locally but don't set
synced = false, the sync worker won't know to send it to the backend. Keep the flag in sync with your local changes. - Ignoring network errors in the sync worker: Catching
IOExceptionand returningResult.retry()is essential. Uncaught exceptions cause the worker to fail permanently.
Key takeaways
- Build your UI as a stateless function of ViewModel state. Use
collectAsStateto bridge StateFlow to Compose. - Use StateFlow in the ViewModel to hold state, and use
stateInwithWhileSubscribedto convert repository flows efficiently. - Leverage Room's Flow queries for automatic UI updates whenever data changes; no manual refresh logic needed.
- Use WorkManager for background sync with appropriate constraints (network connectivity, battery state) and retry policies (exponential backoff).
- Always set a synced flag on local changes so the sync layer knows which items need to be sent to the backend.
- Test your ViewModel with a FakeRepository and your UI with Compose test rules; test the Worker with TestWorkerBuilder.






Comments
Loading comments…