Part 3 – Reactive Mood Dashboard

We now have a generated SQLite API, tagging workflow, and typed repositories. In this article we wire everything into a cross-platform UI, lean on SQLiteNow’s query flows, and show how the ViewModel keeps the screen up to date with almost no glue code.

We will:

  • promote the generated selectRecentWithTags query to a shared StateFlow,
  • compute a “this week” mood summary that recomputes whenever data changes,
  • build a Compose screen with a quick-add form, the weekly summary card, and a reactive list,
  • ensure the Android build stores the database under app-private storage, and
  • round the weekly average to a single decimal without using platform-specific formatters.

Step 1 – Add a Flow-backed ViewModel

Create the ViewModel in composeApp/src/commonMain/kotlin/dev/goquick/sample/moodtracker/data/MoodTrackerViewModel.kt. The ViewModel turns the generated query runners into hot StateFlow instances so the UI can collect them safely.

class MoodTrackerViewModel(
    database: MoodTrackerDatabase,
    private val entryRepository: MoodEntryRepository,
    private val scope: CoroutineScope,
    recentLimit: Int = 30,
) {
    private val entriesFlow = database.moodEntry
        .selectRecentWithTags(
            MoodEntryQuery.SelectRecentWithTags.Params(limit = recentLimit.toLong())
        )
        .asFlow()

    val entries: StateFlow<List<MoodEntryWithTags>> = entriesFlow.stateIn(
        scope = scope,
        started = SharingStarted.WhileSubscribed(stopTimeoutMillis = 5_000),
        initialValue = emptyList(),
    )

    val weeklySummary: StateFlow<WeeklySummary> = entriesFlow
        .map { computeWeeklySummary(it) }
        .stateIn(
            scope = scope,
            started = SharingStarted.WhileSubscribed(stopTimeoutMillis = 5_000),
            initialValue = WeeklySummary.Empty,
        )

    val isSaving: StateFlow<Boolean>
        field: MutableStateFlow<Boolean> = MutableStateFlow(false)

    fun addEntry(moodScore: Int, note: String) {
        if (isSaving.value) return
        scope.launch {
            isSaving.value = true
            try {
                entryRepository.add(
                    MoodEntryRepository.NewMoodEntry(
                        entryTime = Clock.System.now(),
                        moodScore = moodScore,
                        note = note.ifBlank { null },
                    )
                )
            } finally {
                isSaving.value = false
            }
        }
    }

    private fun computeWeeklySummary(entries: List<MoodEntryWithTags>): WeeklySummary {
        if (entries.isEmpty()) return WeeklySummary.Empty

        val timeZone = TimeZone.currentSystemDefault()
        val today = Clock.System.now().toLocalDateTime(timeZone).date
        val weekStart = today.startOfWeek()
        val weekEntries = entries.filter {
            it.entryTime.toLocalDateTime(timeZone).date >= weekStart
        }
        if (weekEntries.isEmpty()) {
            return WeeklySummary.Empty.copy(startDate = weekStart, endDate = today)
        }

        val average = weekEntries.map { it.moodScore }.average()
        return WeeklySummary(
            averageScore = average,
            entryCount = weekEntries.size,
            startDate = weekStart,
            endDate = today,
        )
    }

    data class WeeklySummary(
        val averageScore: Double?,
        val entryCount: Int,
        val startDate: LocalDate,
        val endDate: LocalDate,
    ) {
        companion object {
            val Empty = WeeklySummary(
                averageScore = null,
                entryCount = 0,
                startDate = LocalDate(1970, 1, 1),
                endDate = LocalDate(1970, 1, 1),
            )
        }
    }
}

private fun LocalDate.startOfWeek(): LocalDate {
    val dayOffset = (dayOfWeek.ordinal - DayOfWeek.MONDAY.ordinal + 7) % 7
    return this.minus(DatePeriod(days = dayOffset))
}
  • The database and generated models keep entryTime as kotlin.time.Instant. The toLocalDateTime(timeZone) calls convert an instant only while assigning it to a local calendar week. They do not change the persisted type or write a zone-free LocalDateTime to SQLite.
  • stateIn turns the cold SQLiteNow flow into a hot StateFlow, caching the latest rows so every collector sees the same list immediately. Because selectRecentWithTags.sql uses a dynamic-field collection, each element already includes its tags: List<MoodTagRow>.
  • We stamp new entries with Clock.System.now() and guard the insert with isSaving to avoid double taps. Kotlin 2.4’s explicit backing field lets the ViewModel mutate a MutableStateFlow while callers receive the read-only StateFlow<Boolean> interface.
  • The summary helpers live alongside the ViewModel and reuse the generated row model to compute the average and streak window.

Behind the scenes, SQLiteNow keeps the query flow hot: every insert, update, or delete that touches mood_entry will push new rows downstream. Feeding that flow into stateIn gives Compose a single source of truth. Screens simply collectAsState() and redraw; no manual observers, cursors, or DAO callbacks are needed.

Step 2 – Compose Screen Powered by Flows

Replace the template UI in App.kt with the reactive surface. The screen wires the ViewModel, quick-add controls, weekly summary, and the entries list together. Mood buttons use a tonal style for the selected value so the user can see what is active.

// App.kt – entry points

@Composable
@Preview
fun App() {
    MaterialTheme {
        Surface(modifier = Modifier.fillMaxSize()) {
            val databaseState = rememberDatabase()
            val database = databaseState.value
            if (database == null) {
                LoadingState()
            } else {
                MoodTrackerContent(database)
            }
        }
    }
}

@Composable
private fun rememberDatabase(): MutableState<MoodTrackerDatabase?> {
    val state = remember { mutableStateOf<MoodTrackerDatabase?>(null) }
    LaunchedEffect(Unit) {
        if (state.value == null) {
            state.value = MoodDatabaseFactory().create()
        }
    }
    return state
}

@Composable
private fun MoodTrackerContent(database: MoodTrackerDatabase) {
    val scope = rememberCoroutineScope()
    val viewModel = remember(database) {
        MoodTrackerViewModel(
            database = database,
            entryRepository = MoodEntryRepository(database),
            scope = scope,
        )
    }

    val entries by viewModel.entries.collectAsState()
    val summary by viewModel.weeklySummary.collectAsState()
    val isSaving by viewModel.isSaving.collectAsState()

    MoodTrackerScreen(
        entries = entries,
        summary = summary,
        isSaving = isSaving,
        onAdd = { score, note -> viewModel.addEntry(score, note) },
    )
}

collectAsState() bridges the hot flows from the ViewModel into Compose state. Whenever the database notifies a change, entries and summary update automatically and the UI re-composes. Because each item is a MoodEntryWithTags, the screen can render tag chips without additional queries or grouping logic.

Next, build the screen scaffold. This piece arranges the quick-add card, weekly summary, and list while delegating the individual blocks to helper composables.

// App.kt – screen scaffold

@Composable
private fun MoodTrackerScreen(
    entries: List<MoodEntryWithTags>,
    summary: MoodTrackerViewModel.WeeklySummary,
    isSaving: Boolean,
    onAdd: (Int, String) -> Unit,
) {
    var note by remember { mutableStateOf("") }
    var selectedMood by remember { mutableStateOf(3) }

    Column(
        modifier = Modifier
            .fillMaxSize()
            .padding(horizontal = 20.dp, vertical = 24.dp),
        verticalArrangement = Arrangement.spacedBy(16.dp),
    ) {
        Text(text = "Mood Tracker", style = MaterialTheme.typography.headlineSmall)

        QuickAddBlock(
            note = note,
            onNoteChange = { note = it },
            selectedMood = selectedMood,
            onMoodChange = { selectedMood = it },
            onAdd = {
                onAdd(selectedMood, note)
                note = ""
            },
            enabled = !isSaving,
        )

        WeeklySummaryCard(summary = summary)

        EntriesList(
            entries = entries,
            modifier = Modifier.weight(1f, fill = true),
        )
    }
}

Finally, drop in the helper composables that render the quick-add form, mood picker, summary card, entry list, and loading indicator.

// App.kt – helpers

@Composable
private fun QuickAddBlock(
    note: String,
    onNoteChange: (String) -> Unit,
    selectedMood: Int,
    onMoodChange: (Int) -> Unit,
    onAdd: () -> Unit,
    enabled: Boolean,
) {
    Card {
        Column(
            modifier = Modifier.padding(16.dp),
            verticalArrangement = Arrangement.spacedBy(12.dp),
        ) {
            Text(
                text = "Add how you feel right now",
                style = MaterialTheme.typography.titleMedium,
            )

            OutlinedTextField(
                value = note,
                onValueChange = onNoteChange,
                modifier = Modifier.fillMaxWidth(),
                placeholder = { Text("Optional note") },
                maxLines = 3,
                keyboardOptions = KeyboardOptions.Default.copy(imeAction = ImeAction.Done),
                keyboardActions = KeyboardActions(onDone = { onAdd() }),
            )

            MoodPicker(
                selectedMood = selectedMood,
                onMoodChange = onMoodChange,
            )

            Button(
                onClick = onAdd,
                enabled = enabled,
                modifier = Modifier.align(Alignment.End),
            ) {
                Text("Add entry")
            }
        }
    }
}

@Composable
private fun MoodPicker(
    selectedMood: Int,
    onMoodChange: (Int) -> Unit,
) {
    Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
        Text(text = "Mood score", style = MaterialTheme.typography.labelLarge)
        Row(
            horizontalArrangement = Arrangement.spacedBy(8.dp),
            modifier = Modifier.fillMaxWidth(),
        ) {
            (1..5).forEach { score ->
                val selected = score == selectedMood
                val buttonModifier = Modifier.weight(1f)
                if (selected) {
                    FilledTonalButton(
                        onClick = { onMoodChange(score) },
                        modifier = buttonModifier,
                    ) {
                        Text(
                            text = score.toString(),
                            style = MaterialTheme.typography.titleMedium,
                        )
                    }
                } else {
                    OutlinedButton(
                        onClick = { onMoodChange(score) },
                        modifier = buttonModifier,
                    ) {
                        Text(
                            text = score.toString(),
                            style = MaterialTheme.typography.bodyMedium,
                        )
                    }
                }
            }
        }
    }
}

@Composable
private fun WeeklySummaryCard(summary: MoodTrackerViewModel.WeeklySummary) {
    Card(
        colors = CardDefaults.cardColors(
            containerColor = MaterialTheme.colorScheme.secondaryContainer
        ),
    ) {
        Column(
            modifier = Modifier.padding(16.dp),
            verticalArrangement = Arrangement.spacedBy(8.dp),
        ) {
            Text("This week so far", style = MaterialTheme.typography.titleMedium)
            if (summary.entryCount == 0) {
                Text(
                    text = "No entries yet. Add how you feel to start tracking your week.",
                    style = MaterialTheme.typography.bodyMedium,
                )
            } else {
                val formattedAverage = summary.averageScore?.let { avg ->
                    val scaled = (avg * 10.0).roundToInt() / 10.0
                    scaled.toString()
                }
                Text("Entries: ${summary.entryCount}", style = MaterialTheme.typography.bodyMedium)
                Text(
                    text = "Average mood: ${formattedAverage ?: "-"}",
                    style = MaterialTheme.typography.bodyMedium,
                )
            }
        }
    }
}

@Composable
private fun EntriesList(
    entries: List<MoodEntryWithTags>,
    modifier: Modifier = Modifier,
) {
    if (entries.isEmpty()) {
        Box(
            modifier = modifier.fillMaxWidth(),
            contentAlignment = Alignment.Center,
        ) {
            Text(
                text = "No recent entries yet.",
                style = MaterialTheme.typography.bodyMedium,
                textAlign = TextAlign.Center,
            )
        }
        return
    }

    LazyColumn(
        modifier = modifier.fillMaxWidth(),
        contentPadding = PaddingValues(vertical = 8.dp),
        verticalArrangement = Arrangement.spacedBy(8.dp),
    ) {
        items(entries) { entry ->
            EntryRow(entry = entry)
        }
    }
}

@Composable
private fun EntryRow(entry: MoodEntryWithTags) {
    Card {
        Column(
            modifier = Modifier
                .fillMaxWidth()
                .padding(16.dp),
            verticalArrangement = Arrangement.spacedBy(8.dp),
        ) {
            Text(
                text = entry.entryTime.toString(),
                style = MaterialTheme.typography.labelMedium,
            )
            Text(
                text = "Mood score: ${entry.moodScore}",
                style = MaterialTheme.typography.bodyMedium,
            )
            entry.note?.takeIf { it.isNotBlank() }?.let {
                HorizontalDivider()
                Text(
                    text = it,
                    style = MaterialTheme.typography.bodyLarge,
                )
            }
            if (entry.tags.isNotEmpty()) {
                HorizontalDivider()
                Text(
                    text = entry.tags.joinToString(separator = ", ") { tag -> tag.name },
                    style = MaterialTheme.typography.bodySmall,
                    color = MaterialTheme.colorScheme.primary,
                )
            }
        }
    }
}

@Composable
private fun LoadingState() {
    Box(
        modifier = Modifier.fillMaxSize(),
        contentAlignment = Alignment.Center,
    ) {
        CircularProgressIndicator()
    }
}

Fill in the helper bodies directly from App.kt; they mirror the repository version exactly and keep the tutorial snippets short.

Step 3 – Point SQLiteNow at Real Files

SQLiteNow already ships a multiplatform helper named resolveDatabasePath (dev.goquick.sqlitenow.common.resolveDatabasePath). It returns the appropriate location on every target: Android uses the app’s database directory, JVM/desktop falls back to a user-specific folder, iOS stores inside Documents, and special names such as ":memory:" are passed through untouched. Because this helper already covers every platform we no longer need a project-specific expect/actual pair.

Only Android requires a small bootstrap call so the helper can read an application context. Keep MainActivity in composeApp/src/androidMain; the separate androidApp module packages that shared Android target as an application. Desktop, iOS, JS, and wasm need no extra code.

// MainActivity.kt

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        enableEdgeToEdge()
        super.onCreate(savedInstanceState)

        setupAndroidAppContext(applicationContext)
        setContent { App() }
    }
}

MoodDatabaseFactory now relies on the library helper as well:

// MoodDatabaseFactory.kt

val resolvedName = if (dbName.startsWith(":")) {
    dbName
} else {
    resolveDatabasePath(dbName = dbName, appName = "MoodTracker")
}
val database = MoodTrackerDatabase(
    dbName = resolvedName,
    migration = VersionBasedDatabaseMigrations(),
    debug = debug,
    // ... adapters ...
)

Nothing else from Part 1 or Part 2 needs to change because the helper already handles every target.

Step 4 – Regenerate and Test

Regenerate the database, run the shared tests on JVM, compile them for the Android device target, and build the APK. Run the connected test when an emulator or device is available.

./gradlew :composeApp:generateMoodTrackerDatabase
./gradlew :composeApp:jvmTest
./gradlew :composeApp:compileAndroidDeviceTest
./gradlew :androidApp:assembleDebug
./gradlew :composeApp:connectedAndroidDeviceTest

Where We Stand

  • MoodTrackerViewModel keeps query flows hot with stateIn, computes a weekly summary, and exposes an isSaving flag for the UI.
  • The Compose screen renders a quick-add form, weekly digest, and reactive list whose rows list tags pulled straight from the dynamic-field collection—no manual grouping required.
  • The Android build uses SQLiteNow’s shared resolveDatabasePath helper so the file lives under app-private storage; desktop and iOS share the same helper automatically.
  • Weekly averages now round to one decimal in a multiplatform-friendly way.

That wraps up the Mood Tracker series. You now have a reactive KMP UI backed by SQLiteNow: typed queries, flows, and a compose surface that stays in sync across Android, iOS, desktop, and beyond. Happy shipping!