Skip to content
ExponaBLOG
MODERN ANDROID & KOTLIN · DAY 075 / 100

Building a real app: shopping list — a step-by-step guide

A full feature from UI to storage.

Expona Admin8 min read

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:

kotlin
@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:

kotlin
@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.

kotlin
@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.

kotlin
@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:

kotlin
@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:

kotlin
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:

kotlin
@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 fun in the DAO and launching on viewModelScope ensures 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 IOException and returning Result.retry() is essential. Uncaught exceptions cause the worker to fail permanently.

Key takeaways

  • Build your UI as a stateless function of ViewModel state. Use collectAsState to bridge StateFlow to Compose.
  • Use StateFlow in the ViewModel to hold state, and use stateIn with WhileSubscribed to 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.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles