Pocket Studio Academy
HomePart 55.13

Focus Flow 2 — the timer engine

Full course17 min read·4 questions

Build the clock that actually counts. A ViewModel holds every piece of timer state, a coroutine ticks ten times a second without freezing the screen, and the loop measures real elapsed time instead of trusting delay to be exact.

Making a clock is harder than it looks

You want a number that goes down. How hard can it be?

the obvious idea — do not write thiskotlin
1while (left > 0) {
2    Thread.sleep(100)
3    left = left - 100
4}

Two things are wrong with those four lines, and both of them are fatal.

It freezes the phone. Thread.sleep stops whichever thread it is on. If that is the — and in a click handler it is — then for the next twenty-five minutes Android cannot draw a frame, cannot handle a tap, cannot do anything. After about five seconds the system shows the "Focus Flow isn't responding" dialog. That is an .

It drifts. Thread.sleep(100) does not sleep for 100 milliseconds. It sleeps for at least 100 milliseconds, and then waits for the operating system to get round to waking it. On a busy phone each pass might really take 104. Multiply that by 15,000 passes and your twenty-five minute timer takes twenty-six.

Both problems have the same shape: the naive loop assumes it owns the phone and assumes time behaves. This lesson fixes both.

Think of it like this

Think about boiling an egg while cooking dinner.

The wrong way is to stand and stare at the egg for four minutes. Nothing else in the kitchen gets done. That is Thread.sleep on the main thread.

The right way is to set a timer and carry on chopping. Every so often you glance at the clock on the wall. When you glance, you do not guess how long you have been chopping — you read the clock and subtract. That is a with , and it is why the loop below reads the clock on every pass instead of assuming.

Two moods and nothing else

A Pomodoro is only ever focusing or resting. That is an , and giving it a little behaviour of its own saves an if in three places later.

ui/timer/Phase.ktkotlin
1package com.nativeworks.focusflow.ui.timer
2
3// A Pomodoro only ever has two moods.
4enum class Phase(val label: String) {
5    FOCUS("Focus"),
6    BREAK("Break"),
7    ;
8
9    val other: Phase
10        get() = if (this == FOCUS) BREAK else FOCUS
11}

The lonely ; on its own line is required Kotlin: it closes the list of enum entries so that members can follow. other is a — there is no stored value, it works the answer out each time it is read. Phase.FOCUS.other is Phase.BREAK, and that is all the phase-flipping logic this app will ever need.

Everything the timer knows

One holding the whole of the timer's :

ui/timer/TimerViewModel.ktkotlin
1const val MINUTE_MS = 60_000L
2private const val TICK_MS = 100L
3
4data class TimerUiState(
5    val phase: Phase = Phase.FOCUS,
6    val leftMs: Long = 25L * MINUTE_MS,
7    val totalMs: Long = 25L * MINUTE_MS,
8    val running: Boolean = false,
9    val rounds: Int = 0,
10) {
11    // 1f at the start of a phase, 0f when it ends.
12    val fraction: Float
13        get() = if (totalMs <= 0L) 0f
14        else (leftMs.toFloat() / totalMs.toFloat())
15            .coerceIn(0f, 1f)
16}

Five stored values and one computed one.

leftMs and totalMs are both there on purpose. You need leftMs to draw the clock and totalMs to know how far through the phase you are — and in chapter 5, when the user changes the focus length, comparing the two is how the app works out whether you are mid-round or untouched.

fraction is the number chapter 3's ring dial draws: 1f when a phase has just begun, 0f when it ends. Two guards make it safe. totalMs <= 0L avoids a division by zero. coerceIn(0f, 1f) keeps the answer inside the range even if leftMs briefly overshoots — and it will, because the loop subtracts whole chunks of real time rather than exact ticks.

Notice the underscores in 60_000L. Kotlin ignores them; they are there so you can see at a glance that it is sixty thousand and not six hundred thousand.

The engine

Here is the whole . Read it once, then take the walkthrough.

Why a coroutine and not a thread

A is not a thread. It is a piece of work that knows how to pause in the middle and give the thread back.

When delay(100) runs, the coroutine stops where it is and the thread it was on goes off and does something else — drawing your screen, most likely. A hundred milliseconds later Android resumes the coroutine on the next line. From the code's point of view it looks like sleeping. From the phone's point of view nothing was ever blocked.

That is why the timer can tick ten times a second, forever, and the app still feels instant.

What happens on each tick

ui/timer/TimerViewModel.ktkotlin
1    private fun advance(stepMs: Long) {
2        val s = _state.value
3        val left = s.leftMs - stepMs
4        if (left > 0L) {
5            _state.value = s.copy(leftMs = left)
6            return
7        }
8        finishPhase(s)
9    }
10
11    // A phase that runs out flips straight into the
12    // other one and keeps counting.
13    private fun finishPhase(s: TimerUiState) {
14        val next = s.phase.other
15        val total = lengthOf(next)
16        _state.value = s.copy(
17            phase = next,
18            leftMs = total,
19            totalMs = total,
20            rounds =
21                if (s.phase == Phase.FOCUS) s.rounds + 1
22                else s.rounds,
23        )
24    }
25
26    private fun lengthOf(phase: Phase): Long =
27        when (phase) {
28            Phase.FOCUS -> focusMinutes * MINUTE_MS
29            Phase.BREAK -> breakMinutes * MINUTE_MS
30        }

advance is ten lines and does one thing: take the elapsed milliseconds off leftMs. If there is time left, publish a new state and stop. If there is not, the phase is over.

finishPhase swaps in the other phase, refills the clock to that phase's full length, and bumps the round counter — but only if the phase that just ended was a focus phase. Finishing a break is not an achievement.

The when in lengthOf needs no else branch. Phase has exactly two entries and both are listed, so the compiler knows the when is . Add a LONG_BREAK entry later and this line stops compiling until you decide how long it is — which is exactly the reminder you would want.

Reset

ui/timer/TimerViewModel.ktkotlin
1    fun reset() {
2        pause()
3        val s = _state.value
4        val total = lengthOf(s.phase)
5        _state.value = s.copy(
6            leftMs = total,
7            totalMs = total,
8        )
9    }

Stop the ticker, refill the current phase, leave the phase itself alone. Reset during a break gives you a fresh break, not a surprise focus round.

Turning milliseconds into 25:00

ui/timer/TimerViewModel.ktkotlin
1// 90_000 -> "01:30". Rounds up so a fresh 25 minute
2// phase reads 25:00 and not 24:59.
3fun formatClock(ms: Long): String {
4    val safe = if (ms < 0L) 0L else ms
5    val seconds = (safe + 999L) / 1000L
6    val m = seconds / 60L
7    val s = seconds % 60L
8    return "%02d:%02d".format(m, s)
9}

Four lines of arithmetic, and every one of them is defending against something.

safe clamps negatives to zero, because the loop can overshoot by a few milliseconds on the final tick and -00:-1 is not a time.

(safe + 999L) / 1000L rounds up to the next whole second. Without it, a phase that has just started with 1,500,000 ms left would show 24:59 for the first split second, because integer division throws the remainder away. Rounding up means a fresh twenty-five minute round reads 25:00, which is what a human expects.

/ 60L gives whole minutes, % 60L gives the seconds left over, and with %02d pads each to two digits, so five seconds is 05 and not 5.

All of the maths is done on Long values. That matters: %02d means "a whole number", and handing it a Float throws at runtime rather than at compile time.

The screen

The screen holds no timer state at all. It reads one flow and calls two functions.

ui/timer/TimerScreen.ktkotlin
1@Composable
2fun TimerScreen(modifier: Modifier = Modifier) {
3    val vm: TimerViewModel = viewModel()
4    val state by vm.state.collectAsState()
5
6    TimerContent(
7        state = state,
8        onToggle = vm::toggle,
9        onReset = vm::reset,
10        modifier = modifier,
11    )
12}

viewModel() finds the existing TimerViewModel for this screen or builds one. subscribes to the and gives back a plain value that Compose watches — when the flow emits, this composable recomposes.

vm::toggle is a function reference: the function itself, not a call to it. It is the same thing as writing { vm.toggle() }, only shorter.

Splitting TimerScreen from TimerContent is deliberate. TimerContent takes a state object and two lambdas and knows nothing about ViewModels, so it can be previewed, tested or reused without one. This is applied at the whole-screen level.

ui/timer/TimerScreen.ktkotlin
1@Composable
2private fun TimerContent(
3    state: TimerUiState,
4    onToggle: () -> Unit,
5    onReset: () -> Unit,
6    modifier: Modifier = Modifier,
7) {
8    Column(
9        modifier = modifier
10            .fillMaxSize()
11            .padding(24.dp),
12        horizontalAlignment =
13            Alignment.CenterHorizontally,
14        verticalArrangement = Arrangement.Center,
15    ) {
16        PhasePill(state.phase)
17
18        Spacer(Modifier.height(24.dp))
19
20        Text(
21            text = formatClock(state.leftMs),
22            style =
23                MaterialTheme.typography.displayLarge,
24            color = MaterialTheme.colorScheme.onSurface,
25        )
26
27        Spacer(Modifier.height(8.dp))
28
29        Text(
30            text = roundsLine(state.rounds),
31            style = MaterialTheme.typography.bodyMedium,
32            color =
33                MaterialTheme.colorScheme.onSurfaceVariant,
34        )
35
36        Spacer(Modifier.height(40.dp))
37
38        Controls(
39            running = state.running,
40            onToggle = onToggle,
41            onReset = onReset,
42        )
43    }
44}

The controls

ui/timer/TimerScreen.ktkotlin
1@Composable
2private fun Controls(
3    running: Boolean,
4    onToggle: () -> Unit,
5    onReset: () -> Unit,
6) {
7    Row(
8        horizontalArrangement =
9            Arrangement.spacedBy(16.dp),
10        verticalAlignment = Alignment.CenterVertically,
11    ) {
12        FilledTonalIconButton(
13            onClick = onReset,
14            modifier = Modifier.size(56.dp),
15        ) {
16            Icon(
17                imageVector = Icons.Filled.Refresh,
18                contentDescription = "Reset",
19            )
20        }
21
22        Button(
23            onClick = onToggle,
24            shape = RoundedCornerShape(28.dp),
25            modifier = Modifier
26                .height(56.dp)
27                .widthIn(min = 168.dp),
28        ) {
29            Icon(
30                imageVector =
31                    if (running) Icons.Filled.Pause
32                    else Icons.Filled.PlayArrow,
33                contentDescription = null,
34            )
35            Spacer(Modifier.width(8.dp))
36            Text(
37                text = if (running) "Pause" else "Start",
38                style =
39                    MaterialTheme.typography.titleMedium,
40            )
41        }
42    }
43}

One button, two faces. The icon and the word both switch on running, so there is never a moment where the picture says one thing and the label says another.

contentDescription = null on the play triangle is correct, not lazy: the word next to it already says "Start", and a screen reader announcing "play triangle, Start" would be noise. The reset button, which has no visible label, gets a real description.

widthIn(min = 168.dp) keeps the button the same width whether it says Start or Pause. Without it the button would shrink by a few pixels every time you paused, and the row would twitch.

The phase pill

ui/timer/TimerScreen.ktkotlin
1@Composable
2private fun PhasePill(phase: Phase) {
3    val scheme = MaterialTheme.colorScheme
4    val focus = phase == Phase.FOCUS
5    Surface(
6        color =
7            if (focus) scheme.primaryContainer
8            else scheme.secondaryContainer,
9        contentColor =
10            if (focus) scheme.onPrimaryContainer
11            else scheme.onSecondaryContainer,
12        shape = RoundedCornerShape(50),
13    ) {
14        Text(
15            text = phase.label.uppercase(),
16            style = MaterialTheme.typography.labelLarge,
17            modifier = Modifier.padding(
18                horizontal = 20.dp,
19                vertical = 10.dp,
20            ),
21        )
22    }
23}

RoundedCornerShape(50) with a bare number means fifty percent of the shorter side — a fully rounded pill at any height. contentColor is inherited by everything inside the Surface, which is why the Text never has to name a colour.

Colour carries the meaning here: indigo for focus, teal for break. The word changes too, because colour alone is not enough for someone who cannot distinguish those two hues.

Finally, a small helper that speaks English rather than printing "1 rounds":

ui/timer/TimerScreen.ktkotlin
1private fun roundsLine(rounds: Int): String =
2    when (rounds) {
3        0 -> "No rounds finished yet"
4        1 -> "1 round finished"
5        else -> "$rounds rounds finished"
6    }
9:41▲ ▮
FOCUS
25:00
No rounds finished yet
Start
Timer
Stats
Settings

End of chapter 2. The clock counts, the button toggles, nothing is drawn by hand yet.

Try it in Pocket Studio
  1. Open Pocket StudioProjectsFocus Flow.
  2. Open app/build.gradle.kts and add to dependencies, wrapped over three lines: implementation( / libs.androidx.lifecycle.viewmodel.compose / ). Tap Sync.
  3. Long-press ui/timerNewKotlin File, name it Phase, and type the enum.
  4. Add a second file, TimerViewModel, and type the state class, the ViewModel and formatClock.
  5. Open ui/timer/TimerScreen.kt and replace the whole placeholder with the version above.
  6. Tap Build, then Run ▶.
  7. Tap Start. Watch the digits move and the button become Pause.
  8. While it is running, tap the Stats tab, count to five, and tap Timer again. The clock kept counting — because the state lives in the ViewModel, not the screen.
  9. Rotate the phone. Same thing: the timer does not reset.
  10. Tap the round refresh button. Back to 25:00, still on FOCUS.

Focus Flow — end of chapter 2

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

Download ZIP
Error Doctor5 common errors
e: file:///.../TimerScreen.kt:34:31 Unresolved reference 'viewModel'.
MeansThe viewModel() helper is not on the classpath. lifecycle-runtime-ktx alone does not provide it.
FixAdd lifecycle-viewmodel-compose to app/build.gradle.kts — wrapped over three lines to stay inside 60 characters — then tap Sync and rebuild. It is the Compose artifact that carries viewModel().
java.lang.RuntimeException: Cannot create an instance of class com.nativeworks.focusflow.ui.timer.TimerViewModel Caused by: java.lang.NoSuchMethodException: ...TimerViewModel.<init> []
MeansviewModel() tried to build your ViewModel using a constructor that takes no arguments, and yours takes parameters.
FixIn this chapter the class must be exactly class TimerViewModel : ViewModel() with an empty constructor. From chapter 4 it takes an Application and extends AndroidViewModel, which the default factory does know how to build. Anything else needs a factory of your own.
e: file:///.../TimerViewModel.kt:41:13 Suspend function 'suspend fun delay(timeMillis: Long): Unit' should be called only from a coroutine or another suspend function.
Meansdelay can only pause a coroutine, and you called it from ordinary code.
FixWrap the loop in viewModelScope.launch { ... }, as start() does. Do not swap in Thread.sleep — that compiles and then freezes the entire screen for the length of the phase.
kotlinx.coroutines.JobCancellationException: Job was cancelled; job=SupervisorJobImpl{Cancelled}@3f6a1b2
MeansYou cancelled the scope with viewModelScope.cancel() instead of the job. A cancelled scope is dead permanently, so after this, Start does nothing — every later launch finishes instantly.
FixKeep the handle that launch returns and cancel only that: ticker?.cancel(). viewModelScope is cancelled for you when the ViewModel is destroyed; you never cancel it by hand.
java.util.IllegalFormatConversionException: d != java.lang.Float at java.util.Formatter$FormatSpecifier.failConversion
Means%02d means "a whole number", and you handed it a Float — usually by dividing before converting.
FixDo the division on Long values as formatClock does, so m and s are already whole numbers before they reach format. Long divided by Long is a Long.
Recap
  • Thread.sleep on the freezes the app and earns you an . inside a gives the thread back instead.
  • delay is a minimum, not a promise. The loop reads before and after and subtracts the real gap, so a 25-minute timer takes 25 minutes.
  • launch hands back a . Keep it, cancel it in pause(), and never cancel yourself.
  • All the timer's state lives in one TimerUiState, published through a . Rotating the phone or switching tabs cannot reset it.
  • fraction is a , guarded with — chapter 3 draws it.
  • TimerScreen reads the flow and passes plain values to TimerContent, which knows nothing about ViewModels.
  • Next: the ring dial. You replace the number with a picture of the number, drawn by hand on a with arcs and a little trigonometry.