Focus Flow 5 — settings with DataStore
Build the Settings tab for real. Focus length, break length and a keep-screen-on switch, stored in DataStore, read as a Flow, and applied to the running timer the moment you change them — but never in the middle of a round you are already in.
Twenty-five minutes is somebody else's number
The Pomodoro technique is named after a tomato-shaped kitchen timer that its inventor happened to own in the 1980s. Twenty-five minutes is roughly where that dial felt right to him.
It is a perfectly good starting point. It is a terrible law. Some people work in forty-five minute blocks; some can only hold fifteen. Right now Focus Flow has two hard-coded numbers and no opinion about any of that:
1private var focusMinutes = 25L
2private var breakMinutes = 5LBy the end of this lesson those are gone, replaced by values the user picks, stored on disk, and pushed back into the timer as a — so changing the focus length in the Settings tab updates the dial before you have even navigated back.
Look at the two devices in a heating system.
There is the thermostat on the hallway wall. It holds one number. You turn it, it stays turned, and every part of the house reads the same setting. There is one of it and it is always readable at a glance.
Then there is the gas meter. It never holds an opinion — it just counts. Every unit that goes past is recorded forever, in order, and you would never dream of editing an old reading.
Settings are the thermostat. Sessions are the meter. Those are genuinely different jobs, and that is why this app uses two completely different ways of storing things.
Why not just put the settings in Room?
You already have a database. Three more values would fit in it. So why add a second storage system?
Because they are the wrong shape for a table.
A table is for many rows of the same thing, where you need to search, sort, filter and join. Every one of those powers costs you: a primary key you do not need, a query language you do not need, a version number, and a plan for the day the shape changes.
Settings are one row that always exists. There is exactly one focus length. You never search for it. You never sort it. You just want to read the current value and occasionally write a new one.
is built for precisely that: a small file of keys and values, no schema, no queries. And unlike the it replaced, it never blocks the — reads come back as a Flow and writes are functions.
The rule of thumb, and it holds up well: if you would ever want to ask "which ones", use a database. If the answer is always "the one", use DataStore.
Everything the user can change
1// Everything the user can change, with the values a
2// fresh install starts from.
3data class AppSettings(
4 val focusMinutes: Int = 25,
5 val breakMinutes: Int = 5,
6 val keepScreenOn: Boolean = false,
7)One holds the lot. Defaults live here and nowhere else, which means a fresh install and a corrupted file both end up in exactly the same place.
Further down the same file, just above the class, sit the two limits:
1// Sensible limits so the timer can never be set to
2// zero minutes or to half a day.
3val FOCUS_RANGE = 5..90
4val BREAK_RANGE = 1..30They are public on purpose: the repository uses them to clamp what it writes, and the screen uses them to grey out a button that would go past the limit. One definition, two users, no chance of them drifting apart.
Opening the store exactly once
Here is the rest of the file — everything except AppSettings above and the two ranges, which sit just before the class.
Writing
1 suspend fun setFocusMinutes(value: Int) {
2 store.edit { prefs ->
3 prefs[Keys.FOCUS] =
4 value.coerceIn(FOCUS_RANGE)
5 }
6 }
7
8 suspend fun setBreakMinutes(value: Int) {
9 store.edit { prefs ->
10 prefs[Keys.BREAK] =
11 value.coerceIn(BREAK_RANGE)
12 }
13 }
14
15 suspend fun setKeepScreenOn(value: Boolean) {
16 store.edit { prefs ->
17 prefs[Keys.KEEP_ON] = value
18 }
19 }store.edit { } reads the file, hands you a mutable copy, writes it back, and pushes the new contents down store.data so every collector hears about it. It is a : either the whole block lands or none of it does.
Every one of these is , which is why the screen can never call them directly — see the fourth Error Doctor entry below.
is a belt-and-braces guard. The screen already refuses to push the value past the limits, but the screen is not the only thing that could ever call this, and a 25-minute focus round is a lot less annoying than a 25-hour one.
The ViewModel is thin, and should be
1class SettingsViewModel(
2 app: Application,
3) : AndroidViewModel(app) {
4
5 private val repo = SettingsRepository(app)
6
7 // stateIn turns the cold DataStore flow into a
8 // value the screen can read straight away.
9 val settings: StateFlow<AppSettings> =
10 repo.settings.stateIn(
11 scope = viewModelScope,
12 started = SharingStarted
13 .WhileSubscribed(5_000L),
14 initialValue = AppSettings(),
15 )
16
17 fun setFocusMinutes(value: Int) {
18 viewModelScope.launch {
19 repo.setFocusMinutes(value)
20 }
21 }The same pattern as chapter 4's today, for the same reason: the screen needs something to draw in the split second before the file has been read, and initialValue = AppSettings() gives it the defaults.
The three setter functions all look identical, and that is the point. Each one is a wrapper whose entire job is to move a call into a . The screen gets an ordinary function it can hand to onClick; the repository keeps its suspending signature.
The screen
Three cards, and a shared shell so all three line up.
1// The shared shell every settings row sits in.
2@Composable
3private fun SettingCard(
4 content: @Composable RowScope.() -> Unit,
5) {
6 Card {
7 Row(
8 modifier = Modifier
9 .fillMaxWidth()
10 .padding(16.dp),
11 verticalAlignment =
12 Alignment.CenterVertically,
13 ) {
14 content()
15 }
16 }
17}Look closely at the type of content. It is not @Composable () -> Unit — it is @Composable RowScope.() -> Unit.
That extra RowScope. makes it a function with a receiver. Whatever the caller writes inside the lambda behaves as though it were written directly inside the Row. Which matters, because Modifier.weight(1f) does not exist in general — it only exists inside a or a ColumnScope. Without that one word, the labels in StepperCard cannot claim the spare width and the code does not compile.
1@Composable
2private fun StepperCard(
3 title: String,
4 subtitle: String,
5 value: Int,
6 step: Int,
7 range: IntRange,
8 onChange: (Int) -> Unit,
9) {
10 SettingCard {
11 Labels(
12 title = title,
13 subtitle = subtitle,
14 modifier = Modifier.weight(1f),
15 )
16 StepButton(
17 icon = Icons.Filled.Remove,
18 label = "Less",
19 enabled = value - step >= range.first,
20 onClick = { onChange(value - step) },
21 )
22 Text(
23 text = "$value",
24 style = MaterialTheme.typography.titleLarge,
25 textAlign = TextAlign.Center,
26 color = MaterialTheme.colorScheme.primary,
27 modifier = Modifier.width(48.dp),
28 )
29 StepButton(
30 icon = Icons.Filled.Add,
31 label = "More",
32 enabled = value + step <= range.last,
33 onClick = { onChange(value + step) },
34 )
35 }
36}Modifier.weight(1f) on the labels means "take everything the others do not want", so the buttons and the number sit hard against the right edge of every card regardless of how long the title is.
enabled = value - step >= range.first is the whole of the limit-checking. Material 3 fades a disabled button automatically, so the reader can see they have hit the bottom of the range rather than tapping a dead control and wondering what is broken.
Modifier.width(48.dp) on the number is small and important. Without it the card would jump sideways every time the value crossed from 5 to 10 or 45 to 50, because two digits are wider than one. A fixed width plus TextAlign.Center keeps everything still.
The switch card is the same shell with a different right-hand side:
1@Composable
2private fun SwitchCard(
3 title: String,
4 subtitle: String,
5 checked: Boolean,
6 onChange: (Boolean) -> Unit,
7) {
8 SettingCard {
9 Labels(
10 title = title,
11 subtitle = subtitle,
12 modifier = Modifier.weight(1f),
13 )
14 Switch(
15 checked = checked,
16 onCheckedChange = onChange,
17 )
18 }
19}A holds no state of its own. You give it a value and a function; it calls the function and waits to be told what it now looks like. That round trip goes all the way out to the disk and back through the Flow before the switch moves — which sounds slow and is imperceptible, and means what you see is always what is actually stored.
The timer picks the change up
This is the part with an opinion in it. Two pieces of TimerViewModel, shown together: the init block sits near the top with the other properties, and useSettings is a private function further down, next to lengthOf.
The lengthOf function is the last piece, and it barely changes:
1 private fun lengthOf(phase: Phase): Long =
2 when (phase) {
3 Phase.FOCUS ->
4 settings.focusMinutes * MINUTE_MS
5 Phase.BREAK ->
6 settings.breakMinutes * MINUTE_MS
7 }Two hard-coded numbers became two fields on an object that is refreshed from disk. Everything downstream — reset, finishPhase, the dial's fraction — already called lengthOf, so nothing else in the file needed touching. That is what good structure buys you.
Keeping the screen awake
The last setting is not stored state at all — it is a request to the window.
1 val keepOn by vm.keepScreenOn.collectAsState()
2
3 // Ask the window not to sleep while this screen
4 // is on show, and hand the setting back when the
5 // user leaves it.
6 val view = LocalView.current
7 DisposableEffect(keepOn) {
8 view.keepScreenOn = keepOn
9 onDispose { view.keepScreenOn = false }
10 }Compose has no wrapper for this one, so the code reaches down to the old-style Android View that the whole composition is drawn inside. is how you get hold of it, and is a plain property on it.
The interesting part is the shape of the effect. is for exactly this job: turn something on while I am here, and turn it off when I go. The block runs when the Timer screen appears; onDispose runs when it disappears — or when keepOn changes, since it is the key.
Without onDispose, switching to the Stats tab would leave the flag set and the phone would never sleep again until the app was killed. That is the kind of bug that gets one-star reviews about battery life.
and survive a restart.
The Settings tab. Focus has been nudged up to 45, and keep-screen-on is on — both already saved.
- Open Pocket Studio → Projects → Focus Flow →
app/build.gradle.kts. Add todependencies, wrapped over three lines:implementation(/libs.androidx.datastore.preferences/). Tap Sync. - Long-press the
datapackage → New → Kotlin File, name itSettingsRepository, and typeAppSettings, the store property,Keys, the two ranges and the class. - Long-press
ui/settings→ New → Kotlin File, name itSettingsViewModel. - Open
ui/settings/SettingsScreen.ktand replace the whole placeholder with the real screen —SettingsScreen,SettingsContent,StepperCard,SwitchCard,SettingCard,LabelsandStepButton. - Open
ui/timer/TimerViewModel.kt. Delete the twofocusMinutes/breakMinuteslines, addrepo,settings,keepScreenOn, theinitblock anduseSettings, and changelengthOfto read fromsettings. - Open
ui/timer/TimerScreen.ktand add the four lines ofDisposableEffect. - Tap Build, then Run ▶, then the Settings tab.
- Tap + on Focus length four times. The number goes 25 → 45 in fives.
- Tap the Timer tab. The dial reads
45:00. - Tap Start, let it run for a few seconds, then Pause. Go back to Settings and tap + again. Return to Timer: the clock has not jumped — you are mid-round, so the new length waits.
- Tap the round reset button on the Timer tab. Now it picks the new length up.
- Tap − on Focus length until it reaches 5. The minus button greys out — that is
FOCUS_RANGEdoing its job. - Swipe the app away, reopen it, and go to Settings. Your numbers are still there.
Focus Flow — end of chapter 5
A complete project. Unzip it, open it in Pocket Studio, and press Run.
preferencesDataStore(...) is called inside a class or a function, so a brand new store is created every time that code runs.Context, written once in the whole app. Every repository then reads that same property and gets the same store.implementation(libs.androidx.datastore.preferences) — wrapped over three lines to stay inside 60 characters — and tap Sync. Note that it is the preferences artifact you want; plain datastore-core does not contain this function.Modifier.weight() only exists inside a Row or a Column, and your composable has no way of knowing it is inside one. Compose enforces that with receiver types rather than at runtime.content: @Composable RowScope.() -> Unit. That single word is what makes weight legal in StepperCard.store.edit can only run inside a coroutine.vm::setFocusMinutes, and the ViewModel wraps the real work in viewModelScope.launch { ... }. That thin wrapper is the entire reason those three ViewModel functions exist.intPreferencesKey("keep_screen_on") in one file and booleanPreferencesKey("keep_screen_on") in another. DataStore trusts the name and finds the wrong kind of value in the file.Keys object, and always read it through that object. If you have already written a bad value, uninstall the app to clear the file — the wrong type is now on disk.- Use a database when you would ever ask "which ones". Use when the answer is always "the one".
- The store is a top-level extension property on
Context, created through a . Declared once, it is impossible to open the same file twice. - Reads come out of
store.dataas a . turns anIOExceptioninto empty preferences, and turns loose keys into oneAppSettings, so the rest of the app never sees a key at all. - Writes are and happen inside
store.edit { }. The screen calls the ViewModel, and the ViewModel calls the repository inside . @Composable RowScope.() -> Unitis what makesModifier.weightlegal inside a shared card shell. The receiver type is not decoration.- New lengths are applied at once only when the timer is idle and untouched. Changing a setting must never steal a round somebody is in the middle of.
- is the "on while I am here, off when I go" tool. without an
onDisposeis a battery-life bug. - Next: the phone goes face down. A notification fires when a phase ends — which needs a channel, a declared permission, and a runtime request on Android 13 and newer.