Focus Flow 4 — sessions in Room
Give the app a memory. Every finished phase is written into a Room database as a row, and the line under the clock stops counting in memory and starts reading today's real totals straight out of the database — updating itself after every write.
Swipe it away and it never happened
Do four rounds. Swipe Focus Flow out of the recents list. Open it again.
No rounds finished yet.
Everything the app knows lives in a TimerUiState object, and that object lives in a , and the ViewModel dies with the . Chapter 2 was careful to survive a rotation and a tab switch — but a rotation is a hiccup, and being swiped away is death.
For a timer that is fatal, because the whole point of a focus timer is the record. You want to know you did four rounds today. You want to know you did more this week than last. None of that is possible while the truth lives in memory.
This chapter gives Focus Flow a disk.
Think about the till roll in a shop.
Every sale prints one line: what, how much, what time. Nobody ever goes back and edits a line — that would be fraud. The roll only ever grows. At the end of the day the manager does not ask the till what today's takings were; she tears off the roll, finds this morning's first line, and adds up everything after it.
That is exactly what this chapter builds. Each finished phase prints one line. Nothing is ever edited. "How much have I done today" is not something the app remembers — it is a question you ask the roll.
Which turns out to be the more reliable design, because a roll cannot forget and a memory can.
The same three pieces, a different shape of data
You met in Lesson 4.5 and used it for real in Pocket Notes: an for the shape of a row, a for the questions you are allowed to ask, and a @Database that ties them together and names the file.
Focus Flow uses the same three pieces, but the data is a completely different animal.
A note is a thing. You create it, you edit it, you delete it, and the list of them is the state of the world.
A session is an event. It happened. It happened at a particular moment, it lasted a particular length, and there is no meaningful way to edit it afterwards. The table only ever grows.
That difference shows up everywhere below: there is no update, there is no delete, and every question you ask is about a window of time rather than about a particular row.
One finished phase, as a row
1package com.nativeworks.focusflow.data
2
3import androidx.room.Entity
4import androidx.room.PrimaryKey
5
6// One finished phase. Room turns this class into a
7// table: one column per property.
8@Entity(tableName = "sessions")
9data class Session(
10 @PrimaryKey(autoGenerate = true)
11 val id: Long = 0L,
12 // When the phase ended, in milliseconds since
13 // 1 January 1970 (the standard clock reading).
14 val endedAt: Long,
15 val minutes: Int,
16 // true for a focus phase, false for a break.
17 val focus: Boolean,
18)Four columns. Every one of them earns its place.
id is the and you never set it. The flag means picks the next number itself. The default of 0L is the signal: zero means "I do not have one, please choose".
endedAt is a Long, not a date object. It holds : how many thousandths of a second have passed since midnight on 1 January 1970, UTC. That sounds eccentric and it is the single most sensible way to store a moment. It is one plain number, so SQLite can sort it and compare it without knowing anything about calendars. Converting it into a human date is the reading side's job, and Lesson 5.18 does exactly that.
minutes is stored even though you could work it out later, because the setting it was recorded under may have changed by then. A row should say what actually happened, not what the current settings imply happened.
focus distinguishes a work round from a rest. Both get written down — a break you took is real history — but only focus rows are counted.
The two questions the app asks
That last point deserves a sentence on its own, because it is the reason this chapter is short.
You are about to write exactly one line of code that inserts a row. You will not write a single line that updates the screen afterwards. Room notices the write, re-runs since, and the new list flows out to the ViewModel, which recomputes the totals, which recomposes the text. The whole chain is automatic and you built none of it.
The file
1@Database(
2 entities = [Session::class],
3 version = 1,
4 exportSchema = false,
5)
6abstract class FocusDatabase : RoomDatabase() {
7
8 abstract fun sessionDao(): SessionDao
9
10 companion object {
11 // Opening the database is expensive, so the
12 // whole app shares one instance.
13 @Volatile
14 private var instance: FocusDatabase? = null
15
16 fun get(context: Context): FocusDatabase =
17 instance ?: synchronized(this) {
18 instance ?: build(context).also {
19 instance = it
20 }
21 }
22
23 private fun build(context: Context) =
24 Room.databaseBuilder(
25 context.applicationContext,
26 FocusDatabase::class.java,
27 "focus-flow.db",
28 ).build()
29 }
30}You have seen this shape before in Pocket Notes: a , a field, a block, one shared .
There is one detail here that is genuinely different and worth thirty seconds. Look at how many times instance is checked.
1instance ?: synchronized(this) {
2 instance ?: build(context).also {
3 instance = it
4 }
5}Twice. Once outside the lock and once inside it. The outer check is the fast path: after the first call, instance is set, the short-circuits, and nothing locks anything. The inner check exists because two threads can both fail the outer check at the same instant. One takes the lock; the other waits. When the second one finally gets in, the database has already been built — and without the second check it would cheerfully build another one and overwrite the first.
This is called double-checked locking. It is a small piece of ceremony and it is the difference between "usually one database" and "always one database".
applicationContext rather than the is not optional either. The database outlives every screen, so holding a screen's would keep a dead Activity alive for as long as the app runs. That is a memory leak, and it is the classic one.
Turning the compiler into a code generator
Room writes FocusDatabase_Impl for you at build time, and something has to run to do that writing. That something is .
1plugins {
2 alias(libs.plugins.android.application)
3 alias(libs.plugins.kotlin.android)
4 alias(libs.plugins.kotlin.compose)
5 alias(libs.plugins.ksp)
6}1 implementation(libs.androidx.room.runtime)
2 implementation(libs.androidx.room.ktx)
3 ksp(libs.androidx.room.compiler)The third line uses ksp(...) and not implementation(...). That is not a style choice. implementation means "put this library in my app". ksp means "run this at build time to generate code". Room's compiler is a tool, not a library — ship it inside your APK and it does nothing at all, which produces the very first error in the Error Doctor below.
The ViewModel needs a Context
FocusDatabase.get(context) wants a , and a plain ViewModel does not have one. The fix is a one-word change of base class.
1class TimerViewModel(
2 app: Application,
3) : AndroidViewModel(app) { is a ViewModel that is handed the app's Application object when it is constructed. And Application is a Context — the one, the exact flavour a database wants.
The important part is that viewModel() in TimerScreen still needs no factory. Compose's default factory knows about precisely two constructor shapes: no arguments at all, and one Application. You have just moved from the first to the second, so nothing on the screen side changes. Add a third parameter and you would have to write a — which is Lesson 4.2's territory, and not needed here.
Today's totals, read from disk
rounds is deleted from TimerUiState. It was a number the app kept in its head, and the app no longer keeps anything in its head. In its place:
1// Totals for the current calendar day, read straight
2// out of the database.
3data class TodayTotals(
4 val sessions: Int = 0,
5 val minutes: Int = 0,
6)And here is how it gets filled.
The date maths is one helper, at the bottom of the file:
1// Midnight this morning, as a clock reading.
2private fun startOfToday(): Long =
3 LocalDate.now()
4 .atStartOfDay(ZoneId.systemDefault())
5 .toInstant()
6 .toEpochMilli()Read it right to left and it is four small steps. is today's date with no time attached. atStartOfDay pins it to midnight — in a particular , because midnight is a local idea and the phone's zone is the one the user lives in. toInstant turns that into a moment on the world clock, and toEpochMilli turns that into the plain Long the query wants.
This is , available with no extra dependency because Focus Flow's minSdk is 26. Doing it by hand — dividing milliseconds by 86,400,000 — would be wrong twice a year, in every country that changes its clocks.
Writing the row
1 // A phase that runs out is written down, then
2 // flips into the other one and keeps counting.
3 private fun finishPhase(s: TimerUiState) {
4 save(s.phase, s.totalMs)
5
6 val next = s.phase.other
7 val total = lengthOf(next)
8
9 _state.value = s.copy(
10 phase = next,
11 leftMs = total,
12 totalMs = total,
13 )
14 }
15
16 private fun save(phase: Phase, totalMs: Long) {
17 val row = Session(
18 endedAt = System.currentTimeMillis(),
19 minutes = (totalMs / MINUTE_MS).toInt(),
20 focus = phase == Phase.FOCUS,
21 )
22 viewModelScope.launch {
23 dao.insert(row)
24 }
25 }save runs first, before the phase flips, because it needs to record the phase that just ended rather than the one about to begin.
Notice s.totalMs and not s.leftMs. leftMs at this moment is zero or slightly negative — the phase is over. totalMs is how long the phase was set to run, which is the number a human means by "a 25 minute session".
System.currentTimeMillis() is the wall clock, in epoch milliseconds. This is a different clock from the SystemClock.elapsedRealtime() the ticker uses, and the difference matters. Elapsed realtime counts up from the last boot and never jumps, which makes it right for measuring a duration. The wall clock can be changed by the user or nudged by the network, which makes it wrong for measuring — and the only correct choice for stamping a moment, because it is the one that knows what day it is.
The insert itself is one line inside viewModelScope.launch { }, because dao.insert is and suspending functions need a . The write goes off to a background thread, the timer keeps ticking, and the screen never waits.
The screen barely changes
val today by vm.today.collectAsState()…passed into TimerContent(state, today, ...), and inside the ring dial the second line becomes todayLine(today) instead of roundsLine(state.rounds):
1private fun todayLine(t: TodayTotals): String =
2 when (t.sessions) {
3 0 -> "Nothing logged today"
4 1 -> "1 session, ${t.minutes} min"
5 else -> "${t.sessions} sessions, ${t.minutes} min"
6 }Two flows now feed one screen — state for the clock, today for the totals — and each one updates the parts that depend on it. That is the payoff of keeping in small, separate, honest pieces.
The 0 branch is not a technicality. On a fresh install it is what every single user sees first, and "Nothing logged today" reads as a fact about your day rather than as a broken app.
Chapter 4, reopened after being swiped away. The clock reset — it never survived — but the day's totals came back off the disk.
- Open Pocket Studio → Projects → Focus Flow →
gradle/libs.versions.tomland check thatkspand the threeroomentries are already listed. They are — the version catalogue was set up in chapter 1. - Open
app/build.gradle.kts. Addalias(libs.plugins.ksp)as the last line ofplugins, and the three Room lines todependencies. Tap Sync. - Long-press the
com.nativeworks.focusflowpackage → New → Package, name itdata. - Inside
data, add three Kotlin files:Session,SessionDao,FocusDatabase. - Tap Build. This one is slower than usual — KSP is generating Room's code for the first time.
- Open
ui/timer/TimerViewModel.kt. Change the class header to take anApplicationand extendAndroidViewModel, deleteroundsfromTimerUiState, and addTodayTotals,dao,today,save, andstartOfToday. - Open
ui/timer/TimerScreen.kt. Collectvm.today, pass it intoTimerContent, and swaproundsLinefortodayLine. - Tap Build, then Run ▶. The line inside the dial should read Nothing logged today.
- Rather than waiting 25 minutes, shorten the test: temporarily change
focusMinutesto1LandbreakMinutesto1L, rebuild, and let a round finish. - Now the real test. Swipe Focus Flow out of the recents list completely, then open it again. The clock is back at the top of a fresh phase — but the totals line still knows what you did.
- Put
focusMinutesandbreakMinutesback to25Land5Lbefore moving on.
Focus Flow — end of chapter 4
A complete project. Unzip it, open it in Pocket Studio, and press Run.
alias(libs.plugins.ksp) in the plugins block, and ksp(libs.androidx.room.compiler) in dependencies. Using implementation(...) or annotationProcessor(...) for the compiler produces exactly this crash — the tool ships inside your app and never runs.suspend — Room then moves them off the main thread itself — and call them from a coroutine, as save does with viewModelScope.launch { dao.insert(row) }. For reads, return a Flow, which is never on the main thread either. Do not reach for allowMainThreadQueries().Session out of a row — usually because a property was declared inside the class body instead of in the constructor, so there is no way to pass a value in.val, exactly as shown. Room matches constructor parameters to columns by name and type; anything declared below the constructor is invisible to it.@PrimaryKey(autoGenerate = true) above val id: Long = 0L. The default of 0L is what tells Room "I have not got an id, please pick the next number for me".version = 1, and a database file built from the old shape is already sitting on the phone. Room compares the two and refuses to guess..fallbackToDestructiveMigration() to the builder, which throws the old rows away on any mismatch. For a released app you write a Migration instead, because your users' data is not yours to delete.- A session is an event, not a thing. The table only grows: no updates, no deletes, and every question is about a window of time.
- Moments are stored as in a plain
Long, so SQLite can sort and compare them without knowing anything about calendars. System.currentTimeMillis()stamps when;SystemClock.elapsedRealtime()measures how long. They are different clocks and swapping them causes very confusing bugs.- A DAO function returning a is a . Room re-runs it after every write, so one
dao.insertcall updates the screen with no refresh code anywhere. ksp(...)runs Room's compiler at build time.implementation(...)would ship it inside your app, where it does nothing.- gives the ViewModel an
Application, which is a , which is whatFocusDatabase.getneeds.viewModel()still needs no factory. - Kotlin initialises properties in the order they are written, so
daohas to appear above thetodaythat reads it. - Double-checked locking — testing
instanceoutside the lock and inside it — is what makes "one database" a guarantee rather than a probability. - Next: the Settings tab stops being a placeholder. Focus length, break length and a keep-screen-on switch, stored in and applied to the timer the moment you change them.