Pocket Studio Academy
HomePart 55.15

Focus Flow 4 — sessions in Room

Full course15 min read·4 questions

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 of it like this

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

data/Session.ktkotlin
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

data/FocusDatabase.ktkotlin
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.

the interesting partkotlin
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 .

app/build.gradle.ktskotlin
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}
app/build.gradle.kts — dependencieskotlin
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.

ui/timer/TimerViewModel.ktkotlin
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:

ui/timer/TimerViewModel.ktkotlin
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:

ui/timer/TimerViewModel.ktkotlin
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

ui/timer/TimerViewModel.ktkotlin
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

ui/timer/TimerScreen.ktkotlin
    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):

ui/timer/TimerScreen.ktkotlin
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.

9:41▲ ▮
FOCUS
25:00
2 sessions, 50 min
Start
Up next: break
Timer
Stats
Settings

Chapter 4, reopened after being swiped away. The clock reset — it never survived — but the day's totals came back off the disk.

Try it in Pocket Studio
  1. Open Pocket StudioProjectsFocus Flowgradle/libs.versions.toml and check that ksp and the three room entries are already listed. They are — the version catalogue was set up in chapter 1.
  2. Open app/build.gradle.kts. Add alias(libs.plugins.ksp) as the last line of plugins, and the three Room lines to dependencies. Tap Sync.
  3. Long-press the com.nativeworks.focusflow package → NewPackage, name it data.
  4. Inside data, add three Kotlin files: Session, SessionDao, FocusDatabase.
  5. Tap Build. This one is slower than usual — KSP is generating Room's code for the first time.
  6. Open ui/timer/TimerViewModel.kt. Change the class header to take an Application and extend AndroidViewModel, delete rounds from TimerUiState, and add TodayTotals, dao, today, save, and startOfToday.
  7. Open ui/timer/TimerScreen.kt. Collect vm.today, pass it into TimerContent, and swap roundsLine for todayLine.
  8. Tap Build, then Run ▶. The line inside the dial should read Nothing logged today.
  9. Rather than waiting 25 minutes, shorten the test: temporarily change focusMinutes to 1L and breakMinutes to 1L, rebuild, and let a round finish.
  10. 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.
  11. Put focusMinutes and breakMinutes back to 25L and 5L before moving on.

Focus Flow — end of chapter 4

A complete project. Unzip it, open it in Pocket Studio, and press Run.

Download ZIP
Error Doctor5 common errors
java.lang.RuntimeException: cannot find implementation for com.nativeworks.focusflow.data.FocusDatabase. FocusDatabase_Impl does not exist
MeansRoom's code generator never ran, so the class it was supposed to write is missing and the app cannot open the database at all.
FixTwo things must both be true: 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.
java.lang.IllegalStateException: Cannot access database on the main thread since it may potentially lock the UI for a long period of time.
MeansYou called a database method straight from a click handler or from composition. Disk access takes milliseconds you cannot predict, and Android refuses to let you spend them on the drawing thread.
FixMark DAO writes 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().
e: [ksp] .../Session.kt:9: Entities and POJOs must have a usable public constructor. You can have an empty constructor or a constructor whose parameters match the fields (by name and type).
MeansRoom cannot work out how to build a 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.
FixKeep every column as a constructor val, exactly as shown. Room matches constructor parameters to columns by name and type; anything declared below the constructor is invisible to it.
e: [ksp] .../Session.kt:9: An entity must have at least 1 field annotated with @PrimaryKey
MeansEvery table needs a column that uniquely identifies a row, and yours has not got one.
FixAdd @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".
java.lang.IllegalStateException: Room cannot verify the data integrity. Looks like you've changed schema but forgot to update the version number. You can simply fix this by increasing the version number.
MeansYou added, renamed or retyped a column but left 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.
FixWhile you are learning, uninstall the app — that deletes the file — or add .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.
Recap
  • 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.insert call 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 what FocusDatabase.get needs. viewModel() still needs no factory.
  • Kotlin initialises properties in the order they are written, so dao has to appear above the today that reads it.
  • Double-checked locking — testing instance outside 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.