Focus Flow 7 — the stats chart
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.
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.
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.
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
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.
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.
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.
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
val empty = state.loaded && state.weekSessions == 01 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.
The finished Stats tab: three derived totals, and a week of focus minutes drawn by hand on a Canvas. Today is the teal bar.
- Open Pocket Studio → Projects → Focus Flow.
- Long-press
ui/stats→ New → Kotlin File, name itStatsViewModel, and typeDayBar,StatsUiState, the class,windowStart,summariseandformatMinutes. - Add a second file in
ui/statscalledWeekBarChart, with the composable anddrawBar. - Open
ui/stats/StatsScreen.ktand replace the placeholder with the real screen:StatsScreen,StatsContent,Totals,StatTile,ChartCardandEmptyStats. - Long-press
ui/Placeholder.kt→ Delete. If anything still refers to it, Pocket Studio will underline it — those references are all gone by now. - Tap Build, then Run ▶, then the Stats tab. On a fresh install you get Nothing to show yet.
- Go to Settings, set Focus length to its minimum of 5 and Break length to 1. Go to Timer and let two rounds finish.
- Back to Stats. One bar on today, in teal, with
10above it — and the totals row agreeing with it. - 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.
- Try the guards. In
WeekBarChart, temporarily changeval top = if (peak < 25) 25 else peaktoval top = peak, rebuild, and open Stats on a week with nothing in it. It crashes withdivide by zero. Put the clamp back. - 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.
DrawScope that lives in the text package, and the import is missing.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.rememberTextMeasurer() inside the Canvas { } lambda. That lambda runs at draw time, long after composition has finished, so nothing composable can happen in it.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.maxOf was called on an empty list. days is empty for the very first frame, before the database has answered.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.plot * minutes / peak and peak was 0, because no day in the window had any focus minutes at all.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.Float, and something in your sum came out as Int.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.
- 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 peakremoves a divide-by-zero and a lie at the same time. - Put a
Floatfirst 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
loadedflag 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.