Coming Up for Air

Decompose and Data. Let's See What You Got

Sunday, Jan 11, 2026 |

Mobile App Development (8 Parts Series)

In the last post — months ago (and, yes, I hand typed the em dash, not some soulless AI :) — we added support for the Room database API, so now we can store data, but we have no way of seeing what we’ve saved. We also have no way of giving it data to save. In this post, we’ll tackle the first part by creating views to show what we have, then loading the database with demo data. Let’s dive in.

A Real Custom Screen

Currently, our application has just the demo screen from the generator plus our dummy screen used to demonstrate navigation. Our first step will be to fix that. Let’s start by moving RootComponent and RootContent to the package com.steeplesoft.giftbook.ui.root, and deleting DummyComponent, DummyContent, GreeterComponent, and GreeterContent. This breaks our app, of course, but we’ll clean that up as we go.

Next, let’s create a new component in the package com.steeplesoft.giftbook.ui.home. You are free to organize your classes how you’d like, so if you feel this is overkill, feel free to adjust as needed.

HomeComponent.kt
import com.arkivanov.decompose.ComponentContext

class HomeComponent(
    componentContext: ComponentContext,
    var occasionId: Long? = null
) : ComponentContext by componentContext {

}
HomeContent.kt
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier

@Composable
fun Home(
    component: HomeComponent,
    modifier: Modifier = Modifier
) {
    Text(text = "Hello")
}

And now let’s fix our build:

RootComponent
// ...
private fun child(config: NavigationConfig,
                  componentContext: ComponentContext): ComponentContext {
    return when (config) {
        is NavigationConfig.Home -> HomeComponent(componentContext)
    }
}
RootContent
// ...
when (val component = it.instance) {
    is HomeComponent -> Home(component, childModifier)
}

and, finally

NavigationConfig
@Serializable
sealed interface NavigationConfig {
    @Serializable
    data object Home : NavigationConfig
}

It’s not pretty, but it’s an honest start. :) What we want this screen to something like this:

desired home screen

We’ll not get there completely in this post, but this shows you where we’re heading. Let’s start with page decorations.

Scaffold: Prettier Page Decorations

In the screenshot above, the page has a header and a footer. Those are added in RootContent, as this allows all of our screens to have the same decorations. We’ll do that be replacing this:

    Column(modifier = modifier) {
        Children(
            stack = component.stack,
            modifier = modifier.padding(5.dp),
            animation = stackAnimation(slide()),
        ) {

with this:

Scaffold(
    modifier = Modifier.fillMaxSize(),
    topBar = {
        TopAppBar(
            colors = TopAppBarDefaults.centerAlignedTopAppBarColors(
                containerColor = MaterialTheme.colorScheme.primary,
            ),
            title = {
                Text(
                    text = stringResource(Res.string.app_name),
                    color = MaterialTheme.colorScheme.onPrimary
                )
            }
        )
    }
) { innerPadding ->
    Children(
    stack = component.stack,
    modifier = modifier.padding(innerPadding).padding(5.dp),
    animation = stackAnimation(slide()),
) {
        // ...

I won’t pretend I understand everything about the Scaffold, so I’ll quote the official docs:

The Scaffold composable provides a straightforward API you can use to quickly assemble your app’s structure according to Material Design guidelines. Scaffold accepts several composables as parameters. Among these are the following:

  • topBar: The app bar across the top of the screen.

  • bottomBar: The app bar across the bottom of the screen.

  • floatingActionButton: A button that hovers over the bottom-right corner of the screen that you can use to expose key actions.

Our top bar is pretty basic. We use the Material3 component, TopAppBar, and add a single Text child component that holds the app name. For the record, Res.string.app_name is defined in composeApp/src/commonMain/composeResources/values/strings.xml (you will likely need to create the directory and file):

<resources>
    <string name="app_name">Giftbook</string>
</resources>

The bottom bar is a little more complicated, so we’ll skip that for now.

The Home Screen

If you run the app now, you should have a screen with a purple header (I know. Hideous.) with the text "Giftbook", and a plain 'Hello' just below it. Let’s replace Home with the following:

@Composable
fun Home(
    component: HomeComponent,
    modifier: Modifier = Modifier
) {
    val status by component.requestStatus.subscribeAsState()
    val occasionProgress by component.occasionProgress.subscribeAsState()

    Column(
        modifier = modifier,
        verticalArrangement = Arrangement.spacedBy(16.dp)
    ) {
        AsyncLoad(status) {
            val occasions by component.occasions.subscribeAsState()
            val current: Occasion? by remember { mutableStateOf(component.occasion) }

            ComboBox(
                label = "Current Occasion",
                selected = current,
                onChange = { newValue ->
                    component.onOccasionChange(newValue!!)
                },
                items = occasions,
                itemLabel = { item -> item?.name ?: "--" }
            )

            LazyColumn(modifier = Modifier.testTag("recipientList")) {
                items(occasionProgress) {
                    ElevatedCard(
                        elevation = CardDefaults.cardElevation(defaultElevation = 6.dp),
                        modifier = Modifier.fillMaxWidth().padding(bottom = 10.dp)
                    ) {
                        Column(modifier = Modifier.padding(15.dp)) {
                            Text(it.recipient.name, fontSize = 18.sp)
                        }
                    }
                }
            }
        }
    }
}

There’s a lot of red after that, but, for now, make note of the following:

  • We have two subscribeAsState() calls. These will allow our screen to react implicitly when data changes in the component. We’ll see that shortly.

  • The AsyncLoad component will allow us to display a "loading" screen while we query the database. Admittedly, that’s going to happen really quickly, but it’s a necessary step, or we’ll get really odd errors trying to read data that’s not available yet on the initial screen draw.

  • Inside AsyncLoad, we have a remember { mutableStateOf() } call. This will help us remember (har har) the current state of the UI during "configuration changes" or, in plain English, screen rotations. :P

  • We make use of LazyColumn, which is a component that allows us to make a list of components as a column. Rather than drawing every item in the list every time, though, LazyColumn will only draw the items that are visible at the moment, and will reuse components (if I understand correctly), to reduce memory and increase rendering speed.

For all of this to work, we need to update the component.

The newly renovated HomeComponent

To make the compiler happy — and to make our app work — we need to make HomeComponent look something like this:

// ...
import androidx.compose.runtime.getValue
// ...

class HomeComponent(
    componentContext: ComponentContext,
    var occasionId: Long? = null
) : ComponentContext by componentContext {
    private val giftIdeaDao = db.giftIdeaDao()
    private val occasionDao = db.occasionDao()
    private val recipientDao = db.recipientDao()

    var occasions = MutableValue(listOf<Occasion>())
    var requestStatus = MutableValue(Status.LOADING)
    var occasionProgress: MutableValue<List<OccasionProgress>> = MutableValue(mutableListOf())
    var occasion: Occasion? = null

    init {
        componentContext.doOnResume {
            CoroutineScope(Dispatchers.IO).launch {
                requestStatus.update { Status.LOADING }

                val list = occasionDao.getFutureOccasions()
                occasion =
                    if (occasionId != null)
                        occasionDao.getOccasion(occasionId!!)
                    else
                        list.firstOrNull()

                occasions.update { list }

                occasion?.let {
                    onOccasionChange(it)
                }

                requestStatus.update { Status.SUCCESS }
            }
        }
    }

    fun onOccasionChange(newValue: Occasion) {
        CoroutineScope(Dispatchers.IO).launch {
            occasion = newValue
            val list = recipientDao.getRecipientsForOccasion(newValue.id).map {
                val ideas = giftIdeaDao.lookupIdeasByRecipAndOccasion(it.recipientId, newValue.id)
                OccasionProgress(
                    recipientDao.getRecipient(it.recipientId),
                    newValue.id,
                    targetCount = it.targetCount,
                    actualCount = ideas.filter { idea -> idea.occasionId != null }.size,
                    actualCost = ideas.sumOf { idea -> idea.actualCost ?: 0 },
                    targetCost = it.targetCost
                )
            }

            occasionProgress.update { list }
        }
    }
}

There’s an awful lot of red in this, but there are several classes that you need to add. Most of these, while interesting and necessary, are a bit of a distraction here, so I’ll leave it as an exercise for the reader to get it from the Git repo.

Having said that, let’s break down the changes:

  • We use an init block to register a lifecycle callback, specifically doOnResume. This function will be called when the component is initially created, as well as after the screen/page/view is recreated on rotation, etc.

  • In this function, we load data from the database, but we can’t do it on the UI thread, so we start a coroutine using the IO dispatcher.

  • The first thing we do is set requestStatus to LOADING. If you look at the AsyncLoad usage in HomeContent, you should see that we declared val status by component.requestStatus.subscribeAsState(), and then pass status to AsyncLoad. By doing this, when we update HomeComponent.requestStatus, AsyncLoad will automatically rerender if needed. We’ll make use of that shortly. For now, a value of LOADING gets us a nice spinning loading screen.

  • Next, we call occasionDao.getFutureOccasions() to get all the upcoming occasions defined in the app (see the Git repo if you’d like to see the implementation for that. It’s a pretty simple SQL query).

  • Following that, we get the currently selected Occasion. On initial load, occasionId is null, so we just grab the first occasion in the list. However, if we have navigated to this page (something we haven’t seen yet), occasionId is not null, so we query for that occasion. We could simply filter list, but it’s possible that the user has requested a past occasion (again, via functionality we haven’t seen yet), so, to be safe, we just query the database. It’s all local on the device, so the performance hit is not noticeable.

  • Once we have our Occasion, we call occasions.update, which updates the MutableValue variable, and triggers, potentially, rerenders in the view.

  • If occasion is null, we call onOccasionChange, which will load the details (recipients, etc) for the occasion. That function (shown above) uses similar logic to what we have here, so I’ll not walk through that one. This post is long enough as it is. :)

  • Finally, we call requestStatus.update { Status.SUCCESS }, which will trigger AsyncLoad to rerender, and our actual view is displayed on the screen.

Dummy Data

You should be able to run the app now, but there’s nothing to show. Unfortunately, if my understanding is correct, there is currently no way to ship a pre-populated Room database on Android, so we’ll use a bit of a hack to load some data when the app is installed. In AppDatabase.kt, we need to modify getRoomDatabase() like this:

fun getRoomDatabase(builder: RoomDatabase.Builder<AppDatabase>): AppDatabase {
    val database = builder
        .setDriver(BundledSQLiteDriver())
        .setQueryCoroutineContext(Dispatchers.IO)
        .build()

    loadDemoData(database)

    return database
}

Then, in composeApp/src/commonMain/kotlin/com/steeplesoft/giftbook/database/DemoData.kt, add this:

val mutex = Mutex()
fun loadDemoData(database: AppDatabase) {
    CoroutineScope(Dispatchers.IO).launch {
        mutex.withLock {
            loadRecipients(database)
            loadOccasions(database)
            loadGiftIdes(database)
        }
    }
}

// See Git repo for complete implementation
private suspend fun loadGiftIdes(database: AppDatabase) {
    val dao = database.giftIdeaDao()
    if (dao.getAll().isEmpty()) {
        // ...
    }
}

private suspend fun loadOccasions(database: AppDatabase) {
    val dao = database.occasionDao()
    if (dao.getAll().isEmpty()) {
        // ...
    }
}

private suspend fun loadRecipients(database: AppDatabase) {
    val dao = database.recipientDao()
    if (dao.getAll().isEmpty()) {
        // ...
    }
}

These methods check to see if their respective table is empty, then load data if needed. Once you’ve added that and rerun the application, you should see something like this:

dummy data

And that’s basic data-to-screen logic. I breezed over a bit in this for brevity’s sake, so be sure you check out the Git repo for the complete source. In the next few posts, we’ll add the ability to create occasions, recipients, etc., and then we’ll take a look at dependency injection to see how we can clean up that object instantiation.

If you have any questions, comments, corrections, or complaints, you can find me on X or LinkedIn. Until next time…​