Pocket Studio Academy
HomePart 55.18

Focus Flow 7 — the stats chart

Full course17 min read·4 questions

The last chapter. Every session you have saved becomes three totals and a seven-day bar chart, drawn by hand on a Canvas with measured text and rounded bars — plus a proper empty state for the very first run. Focus Flow is finished, and so is Part 5.

The rows have been piling up

Since chapter 4 every finished phase has been written to disk. Nobody has ever looked at them.

That is about to change, and it is worth noticing how little new plumbing it takes. The database is already there. The DAO already has a query that takes a start time and returns a . All this chapter does is ask the same question over a different window, sort the answer into seven buckets, and draw it.

Which is the shape of most real features: the boring work was done three chapters ago, and today is the day it pays.

You will draw the chart yourself, on a , exactly as you drew the ring dial in chapter 3. No chart library. Not one extra dependency.

Think of it like this

Imagine seven glasses in a row on a shelf, one for each day of the week.

Every time you finish a round you pour a splash into today's glass. At the end of the week you never count anything — you stand back and look along the row. The shape tells you the story: two big ones on Tuesday and Thursday, an empty one on Wednesday, today filling up.

Two things about that row are worth stealing. First, the glasses are all the same width and evenly spaced, so your eye compares heights and nothing else. Second, an empty glass is still there — you can see Wednesday happened and nothing went in it.

Both of those are decisions in the code below, and both are what separate a chart from a picture of some rectangles.

The shape of the answer

Work out what the screen needs before writing anything that draws.

ui/stats/StatsViewModel.ktkotlin
1// One column of the chart.
2data class DayBar(
3    val label: String,
4    val minutes: Int,
5    val today: Boolean,
6)
7
8data class StatsUiState(
9    val days: List<DayBar> = emptyList(),
10    val weekMinutes: Int = 0,
11    val weekSessions: Int = 0,
12    val bestMinutes: Int = 0,
13    // False only until the first read comes back, so
14    // the empty state never flashes on a full week.
15    val loaded: Boolean = false,
16)

DayBar is deliberately dumb. It does not hold a date, or a list of sessions, or anything the drawing code would have to interpret. It holds a letter, a number, and a flag saying whether this one is today. Everything hard has already happened by the time a DayBar exists.

loaded earns its comment. Without it, the very first frame — before the database has answered — has zero sessions, and the screen would flash the "Nothing to show yet" card for a fraction of a second before the real chart appeared. Users notice that, and it reads as a bug. The screen shows the only when loaded is true and the week is genuinely empty.

ui/stats/StatsViewModel.ktkotlin
1class StatsViewModel(
2    app: Application,
3) : AndroidViewModel(app) {
4
5    private val dao =
6        FocusDatabase.get(app).sessionDao()
7
8    val state: StateFlow<StatsUiState> =
9        dao.since(windowStart())
10            .map { rows -> summarise(rows) }
11            .stateIn(
12                scope = viewModelScope,
13                started = SharingStarted
14                    .WhileSubscribed(5_000L),
15                initialValue = StatsUiState(),
16            )
17}

The identical pipeline to chapter 4's today, over a different window. FocusDatabase.get hands back the same the Timer tab is already using, so there is no second database and no synchronising to do. Finish a round on the Timer tab, switch to Stats, and the new bar is already there — Room re-ran the query the moment the row landed.

Seven buckets from a pile of rows

Why LocalDate and not maths on milliseconds

It is tempting to bucket by endedAt / 86_400_000 — milliseconds in a day. It is also wrong, in a way that will not show up in testing.

A day is not always 86,400,000 milliseconds long. In every country that changes its clocks, one day each year is an hour shorter and another is an hour longer. Divide by a constant and your buckets drift out of step with real midnights, and one Sunday in spring your chart quietly puts that morning's session on Saturday.

knows all of this. atZone(zone).toLocalDate() asks the calendar what day it really was in the place the user lives. This is the whole reason minSdk is 26: from Android 8, java.time is on the phone with no extra dependency and no .

Laying out the chart

The chart is a Canvas given a width and a height, and everything else is worked out from those two numbers.

That block leaves out the two pieces of text, which come next. Everything else inside the Canvas is there.

Bars that sit flat

ui/stats/WeekBarChart.ktkotlin
1// A bar with only its top two corners rounded, so it
2// still sits flat on the baseline.
3private fun DrawScope.drawBar(
4    left: Float,
5    right: Float,
6    top: Float,
7    bottom: Float,
8    radius: Float,
9    color: Color,
10) {
11    val corner = CornerRadius(radius, radius)
12    val shape = RoundRect(
13        rect = Rect(left, top, right, bottom),
14        topLeft = corner,
15        topRight = corner,
16        bottomRight = CornerRadius.Zero,
17        bottomLeft = CornerRadius.Zero,
18    )
19    val path = Path()
20    path.addRoundRect(shape)
21    drawPath(path = path, color = color)
22}

DrawScope has a drawRoundRect, but it rounds all four corners by the same amount, and a bar with rounded bottom corners looks like it is hovering above the baseline rather than standing on it.

A lets each corner have its own . Two get corner, two get CornerRadius.Zero. There is no draw command that takes a RoundRect directly, so it goes into a — a shape built up out of pieces — and the path is drawn.

The radius is half the bar's width, which means the top is a perfect semicircle. Half the width is the largest radius that still makes sense; go bigger and Compose clamps it anyway.

Text on a Canvas has to be measured first

A Canvas knows about pixels. It knows nothing about fonts, letter spacing or how wide the string "75" is going to be. So before you can centre a label over a bar, something has to tell you how wide it came out.

ui/stats/WeekBarChart.ktkotlin
1    // Text has to be measured before it can be drawn,
2    // and measuring needs a remembered helper.
3    val measurer = rememberTextMeasurer()
4
5    val labelStyle = TextStyle(
6        color = labelColor,
7        fontSize = 12.sp,
8    )
9    val valueStyle = TextStyle(
10        color = valueColor,
11        fontSize = 11.sp,
12    )
13
14    Canvas(modifier = modifier) {

All three of those live above the Canvas, in the composable body. That is not tidiness — it is required. rememberTextMeasurer() is a composable function, and the Canvas lambda is not composable; it runs at draw time, long after composition is over. Calling it inside gives you the same error the ring dial gave you in chapter 3, in a new costume.

The screen around it

Three tiles across the top, then the chart in its own card.

ui/stats/StatsScreen.ktkotlin
1@Composable
2private fun RowScope.StatTile(
3    value: String,
4    caption: String,
5) {
6    Card(modifier = Modifier.weight(1f)) {
7        Column(
8            modifier = Modifier
9                .fillMaxWidth()
10                .padding(vertical = 16.dp),
11            horizontalAlignment =
12                Alignment.CenterHorizontally,
13        ) {
14            Text(
15                text = value,
16                style = MaterialTheme.typography
17                    .titleLarge,
18                color =
19                    MaterialTheme.colorScheme.primary,
20            )
21            Text(
22                text = caption,
23                style = MaterialTheme.typography
24                    .bodySmall,
25                color = MaterialTheme.colorScheme
26                    .onSurfaceVariant,
27            )
28        }
29    }
30}

private fun RowScope.StatTile — the composable is declared as an extension on , which is the other way of getting Modifier.weight where you need it. Chapter 5 used a receiver on a lambda parameter; this uses one on the function itself. Same idea, and here it also documents the fact that a StatTile only makes sense inside a Row.

Three tiles each with weight(1f) means three equal columns however long the text inside them is, which is what stops the row jumping about when 59m becomes 1h.

ui/stats/StatsViewModel.ktkotlin
1// 135 -> "2h 15m", 45 -> "45m".
2fun formatMinutes(total: Int): String {
3    if (total < 60) return "${total}m"
4    val hours = total / 60
5    val mins = total % 60
6    if (mins == 0) return "${hours}h"
7    return "${hours}h ${mins}m"
8}

Three cases and two early returns. 120 reads 2h, not 2h 0m. Small, and the difference between a number that was formatted and a number that was printed.

The empty state

ui/stats/StatsScreen.ktkotlin
    val empty = state.loaded && state.weekSessions == 0
ui/stats/StatsScreen.ktkotlin
1        if (empty) {
2            EmptyStats()
3        } else {
4            Totals(state)
5            ChartCard(state)
6        }

The tiles and the chart are replaced by one card: a chart icon, "Nothing to show yet", and a line that says exactly what to do about it — "Finish one round on the Timer tab and your first bar appears here."

The heading and the bottom bar stay put, so the screen does not jump when the first bar arrives.

An is not an error message. It is the first thing every new user sees, so it should read like the app working correctly, which is what it is.

One last thing: ui/Placeholder.kt gets deleted. Every one of the three tabs is real now, and dead code is not a souvenir.

9:41▲ ▮
Stats
3h 45m
this week
9
sessions
1h 15m
best day
Focus minutes, last 7 days
50
25
75
25
50
M
T
W
T
F
S
S
Timer
Stats
Settings

The finished Stats tab: three derived totals, and a week of focus minutes drawn by hand on a Canvas. Today is the teal bar.

Try it in Pocket Studio
  1. Open Pocket StudioProjectsFocus Flow.
  2. Long-press ui/statsNewKotlin File, name it StatsViewModel, and type DayBar, StatsUiState, the class, windowStart, summarise and formatMinutes.
  3. Add a second file in ui/stats called WeekBarChart, with the composable and drawBar.
  4. Open ui/stats/StatsScreen.kt and replace the placeholder with the real screen: StatsScreen, StatsContent, Totals, StatTile, ChartCard and EmptyStats.
  5. Long-press ui/Placeholder.ktDelete. If anything still refers to it, Pocket Studio will underline it — those references are all gone by now.
  6. Tap Build, then Run ▶, then the Stats tab. On a fresh install you get Nothing to show yet.
  7. Go to Settings, set Focus length to its minimum of 5 and Break length to 1. Go to Timer and let two rounds finish.
  8. Back to Stats. One bar on today, in teal, with 10 above it — and the totals row agreeing with it.
  9. Watch the live update: leave the app on the Timer tab with a round running, and when it ends switch straight to Stats. The bar has already grown. Nothing refreshed it.
  10. Try the guards. In WeekBarChart, temporarily change val top = if (peak < 25) 25 else peak to val top = peak, rebuild, and open Stats on a week with nothing in it. It crashes with divide by zero. Put the clamp back.
  11. Put Focus length back to 25 and Break length back to 5.

Focus Flow — end of chapter 7

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

Download ZIP
Error Doctor5 common errors
e: file:///.../WeekBarChart.kt:104:17 Unresolved reference 'drawText'.
MeansDrawing text on a canvas is an extension on DrawScope that lives in the text package, and the import is missing.
FixAdd import androidx.compose.ui.text.drawText alongside rememberTextMeasurer. Both live in ui-text, which arrives with the ui dependency you already have — no new library is needed.
e: file:///.../WeekBarChart.kt:47:20 @Composable invocations can only happen from the context of a @Composable function
MeansYou called rememberTextMeasurer() inside the Canvas { } lambda. That lambda runs at draw time, long after composition has finished, so nothing composable can happen in it.
FixCreate the measurer and both TextStyle objects above the Canvas, in the composable body, and only use them inside. This is exactly the same rule that stopped you putting a Text inside the ring dial's Canvas in chapter 3.
java.util.NoSuchElementException at kotlin.collections.CollectionsKt___CollectionsKt.maxOf(_Collections.kt:1531)
MeansmaxOf was called on an empty list. days is empty for the very first frame, before the database has answered.
FixGuard the draw with if (days.isEmpty()) return@Canvas as the first line inside the Canvas. Never assume a list that came from a Flow has arrived yet — for at least one frame, it has not.
java.lang.ArithmeticException: divide by zero
MeansYou scaled the bars with plot * minutes / peak and peak was 0, because no day in the window had any focus minutes at all.
FixClamp the scale, as val top = if (peak < 25) 25 else peak does. That one line earns its keep twice: it removes the crash, and it stops a single ten-minute day being drawn as though it were a record-breaking week.
e: file:///.../WeekBarChart.kt:90:21 Argument type mismatch: actual type is 'kotlin.Int', but 'kotlin.Float' was expected.
MeansCanvas geometry is all Float, and something in your sum came out as Int.
FixMake sure at least one side of the arithmetic is a Float. plot is a Float, so plot * day.minutes / top is fine. Writing day.minutes / top * plot compiles on some paths and is worse than a type error when it does — the division happens in whole numbers first, every fraction becomes 0, and you get a row of flat bars with nothing to tell you why.

Three apps

Stop for a moment, because this is the end of something.

You have built Dice Duel: a two-player game with a hand-drawn die face, a rolling animation and a win state. You have built Pocket Notes: a real database, a list, an editor, swipe to delete with undo, and search. And you have now finished Focus Flow: three tabs, a coroutine timer that does not drift, a dial drawn from arcs and trigonometry, Room, DataStore, notifications with the Android 13 permission handled properly, and a chart drawn from rectangles and measured text.

Every one of those is a real APK. Every one of them is on your phone. Every one of them was built without a computer.

Seventy-odd lessons ago the question was "what is an app, actually?" The answer turned out to be what you can see, what it remembers, and the rules for what happens next — and you have now written all three, three times over, from an empty project to an icon on a home screen.

Now make it yours

Focus Flow is finished, but it is not final. Here are three additions, in increasing order of difficulty. Nobody is going to show you the answers, and you do not need them.

A long break every fourth round. Real Pomodoro has one. Phase is an enum with two entries and an other property — add LONG_BREAK and the compiler will immediately stop building and point at lengthOf, because the when is . That is the compiler helping you find every place that has to change. You will need to count completed focus rounds to know when the fourth one lands, and the database already knows: dao.since(startOfToday()) is a query you have written before. Then add a third length to AppSettings and a third StepperCard to Settings.

A streak counter. How many days in a row have you logged at least one focus session? The data is all in the table already. Widen the window past seven days, group by LocalDate exactly as summarise does, then walk backwards from today counting days that have anything in them and stop at the first that does not. Decide for yourself whether today with nothing in it yet breaks the streak — that is a product decision, not a technical one, and it is the interesting part.

Custom session labels. Let the user type what they are focusing on before they hit Start, and store it on the Session row. This one touches everything you learned in Part 4: a new column means a new version on the @Database and a real Migration, a text field on the Timer screen means new state in TimerUiState, and showing recent labels on the Stats tab means a new DAO query. It is the biggest of the three, and it is the one that will teach you the most.

Take a checkpoint ZIP, break it, and see what happens. That is the whole method.

Recap
  • One over a seven-day window, one map, one — the same pipeline as chapter 4, over a different question.
  • Build the seven columns from the calendar, not from the rows, so an empty day keeps its place in the row.
  • with a key buckets sessions by real midnights. Dividing milliseconds by a day is wrong twice a year, in every country that changes its clocks.
  • Reserve the text bands first, then scale the bars into what is left. On a screen y grows downwards, so a taller bar has a smaller top.
  • Scale to the best day with a floor: if (peak < 25) 25 else peak removes a divide-by-zero and a lie at the same time.
  • Put a Float first in the arithmetic. truncates silently and gives you a row of flat bars with no error message.
  • A inside a gives two rounded corners and two square ones, so bars stand on the baseline instead of hovering above it.
  • Text on a Canvas must be measured before it can be placed. Create the above the Canvas, never inside its lambda.
  • An is the first screen a new user sees. Guard it with a loaded flag so it never flashes before the real data arrives.
  • Next: Part 6 — polish and ship. Icons that look real, empty and error states across all three apps, accessibility, dark theme and contrast, what a release build actually is, what signing means, and what publishing to the Play Store honestly involves.