Focus Flow 1 — navigation skeleton
The last and largest app starts here. You build the project, write a full light and dark colour scheme by hand, draw an adaptive icon as a vector, and wire three tabs together with a bottom navigation bar that has no state of its own.
The empty house
Focus Flow is a timer. You pick a length — twenty-five minutes is the usual — you work until the ring runs out, you take a short break, and you go again. It counts what you did, it draws you a chart of your week, and it taps you on the shoulder when a phase ends.
That is three screens, a database, a settings file, a notification and two hand-drawn graphics. It is the most complicated thing in this course, and you are going to build all of it.
Not today, though. Today you build the house before the furniture arrives.
By the end of this lesson Focus Flow is a real installed with a real icon on your home screen, three tabs you can move between, and a colour scheme that follows your phone into dark mode. Nothing counts down yet. It is still worth being proud of.
Think about moving into a flat.
On day one there is no sofa. What there is is a front door with your name on it, three rooms with doors that open, and light switches that work. You cannot live there yet, but you can walk around it, and every room is where it is going to be forever.
That is chapter 1. The icon is the name on the door. The theme is the light switches. The three tabs are the rooms. Furniture arrives in chapters 2 to 7.
Making the project
You have done this twice. Same shape as Dice Duel and Pocket Notes, with two differences worth noticing.
| Setting | Value |
|---|---|
| Name | Focus Flow |
| Package | com.nativeworks.focusflow |
| Minimum SDK | API 26 |
| Language | Kotlin |
API 26 is deliberate. From API 26 the date library is built into Android. Chapter 7 groups your sessions into calendar days, and doing that properly across a daylight-saving change is genuinely fiddly work that java.time has already done. Choose API 24 and you would need an extra library just to get dates back.
The other difference is the list. Here is the whole app/build.gradle.kts for chapter 1:
1plugins {
2 alias(libs.plugins.android.application)
3 alias(libs.plugins.kotlin.android)
4 alias(libs.plugins.kotlin.compose)
5}
6
7android {
8 namespace = "com.nativeworks.focusflow"
9 compileSdk = 35
10
11 defaultConfig {
12 applicationId = "com.nativeworks.focusflow"
13 minSdk = 26
14 targetSdk = 35
15 versionCode = 1
16 versionName = "1.0"
17 }
18
19 buildTypes {
20 release {
21 isMinifyEnabled = false
22 }
23 }
24
25 compileOptions {
26 sourceCompatibility = JavaVersion.VERSION_17
27 targetCompatibility = JavaVersion.VERSION_17
28 }
29
30 kotlinOptions {
31 jvmTarget = "17"
32 }
33
34 buildFeatures {
35 compose = true
36 }
37}
38
39dependencies {
40 implementation(libs.androidx.core.ktx)
41 implementation(libs.androidx.lifecycle.runtime.ktx)
42 implementation(libs.androidx.activity.compose)
43 implementation(platform(libs.androidx.compose.bom))
44 implementation(libs.androidx.ui)
45 implementation(libs.androidx.ui.graphics)
46 implementation(libs.androidx.ui.tooling.preview)
47 implementation(libs.androidx.material3)
48 implementation(
49 libs.androidx.material.icons.extended
50 )
51 implementation(libs.androidx.navigation.compose)
52}Two lines are new compared with Pocket Notes:
material.icons.extended— the base Material library ships only a handful of icons. The bar-chart icon this app needs for the Stats tab lives in the extended set.navigation.compose— the library, so the three tabs can be three real destinations rather than anifstatement.
Notice that the icons line is wrapped over three lines. Written flat it is 61 characters. The 60-character rule is not decoration in this app; it decides how several files are laid out, and this is the first place it shows.
If Gradle complains it cannot find com.android.application, the pluginManagement block is missing from settings.gradle.kts. It must be the first thing in that file:
1pluginManagement {
2 repositories {
3 google()
4 mavenCentral()
5 gradlePluginPortal()
6 }
7}The manifest
One , one , no permissions yet.
1<?xml version="1.0" encoding="utf-8"?>
2<manifest
3 xmlns:android="http://schemas.android.com/apk/res/android">
4
5 <application
6 android:allowBackup="true"
7 android:icon="@mipmap/ic_launcher"
8 android:label="@string/app_name"
9 android:roundIcon="@mipmap/ic_launcher_round"
10 android:supportsRtl="true"
11 android:theme="@style/Theme.FocusFlow">
12
13 <activity
14 android:name=".MainActivity"
15 android:exported="true"
16 android:label="@string/app_name"
17 android:theme="@style/Theme.FocusFlow">
18 <intent-filter>
19 <action
20 android:name="android.intent.action.MAIN" />
21 <category
22 android:name="android.intent.category.LAUNCHER" />
23 </intent-filter>
24 </activity>
25 </application>
26</manifest>The single space before xmlns:android looks like a typo. It is not. With the usual four-space indent that line is 63 characters wide and you could not read the end of it on the phone. XML does not care about indentation. The rule does.
Colours, written out by hand
Compose can generate a whole Material 3 from one seed colour. Focus Flow does not do that. Every colour is named in one file, so that when chapter 3 asks for "the colour of the ring during a break" there is exactly one place that answer lives.
ui/theme/Color.kt is a long, boring, extremely useful file. Here is the shape of it — the light half, cut down to the colours the app actually leans on:
1package com.nativeworks.focusflow.ui.theme
2
3import androidx.compose.ui.graphics.Color
4
5// Light scheme. Indigo carries the Focus phase,
6// teal carries the Break phase.
7val LightPrimary = Color(0xFF4C4DDC)
8val LightOnPrimary = Color(0xFFFFFFFF)
9val LightPrimaryContainer = Color(0xFFE2E0FF)
10val LightOnPrimaryContainer = Color(0xFF0C0072)
11val LightSecondary = Color(0xFF0F8F7A)
12val LightSecondaryContainer = Color(0xFFA6F2E0)
13val LightOnSecondaryContainer = Color(0xFF00201A)
14val LightBackground = Color(0xFFFCFAFF)
15val LightOnBackground = Color(0xFF1B1B22)
16val LightSurfaceVariant = Color(0xFFE4E1EC)
17val LightOnSurfaceVariant = Color(0xFF47464F)
18val LightOutline = Color(0xFF787680)The dark half is the same list with Dark in front and different numbers — DarkPrimary is 0xFFBFC0FF, a pale lavender that reads clearly on a near-black background, and DarkBackground is 0xFF131318. The real file also names the seven container tones Material 3 uses for cards and bars. It is in the download at the end of this lesson; there is nothing to learn from retyping forty-two colour constants.
The 0xFF at the front of each value is the channel: FF means fully opaque. What follows is ordinary red-green-blue, exactly the hex you would use in a design tool.
One type override
1package com.nativeworks.focusflow.ui.theme
2
3import androidx.compose.material3.Typography
4import androidx.compose.ui.text.TextStyle
5import androidx.compose.ui.text.font.FontFamily
6import androidx.compose.ui.text.font.FontWeight
7import androidx.compose.ui.unit.sp
8
9// Only displayLarge is customised: the clock uses a
10// monospaced face so the digits keep the same width
11// and the readout never jiggles as it counts down.
12val FocusTypography = Typography(
13 displayLarge = TextStyle(
14 fontFamily = FontFamily.Monospace,
15 fontWeight = FontWeight.Light,
16 fontSize = 56.sp,
17 lineHeight = 62.sp,
18 letterSpacing = 1.sp,
19 ),
20)Every other text style stays at the Material 3 default. Only displayLarge changes, and only because of one small thing you would notice immediately if it were missing.
In a normal typeface a 1 is narrower than an 8. A clock counting down from 18:11 to 18:08 would shift sideways by a couple of pixels on every single tick, ten times a second. A face gives every digit the same width, so the readout stays put.
Putting the scheme together
1@Composable
2fun FocusFlowTheme(
3 darkTheme: Boolean = isSystemInDarkTheme(),
4 content: @Composable () -> Unit,
5) {
6 MaterialTheme(
7 colorScheme =
8 if (darkTheme) DarkColors else LightColors,
9 typography = FocusTypography,
10 content = content,
11 )
12}LightColors and DarkColors above it are lightColorScheme(...) and darkColorScheme(...) calls that hand each named colour to its Material 3 role — primary = LightPrimary, onPrimary = LightOnPrimary, and so on down the list.
isSystemInDarkTheme() reads the phone's setting. Because it is a function, Compose watches it: switch the phone to while Focus Flow is open and the whole app repaints. You write nothing extra for that.
The icon, drawn as a vector
No PNGs anywhere in this app. The launcher icon is an made of two layers, and the front layer is a — maths, not pixels, so it is sharp at any size.
1<?xml version="1.0" encoding="utf-8"?>
2<vector
3 xmlns:android="http://schemas.android.com/apk/res/android"
4 android:width="108dp"
5 android:height="108dp"
6 android:viewportWidth="108"
7 android:viewportHeight="108">
8
9 <!-- The dial track: a full circle of radius 25 -->
10 <!-- drawn as two half-circle arcs. -->
11 <path
12 android:pathData=
13 "M29,54 A25,25 0 1 1 79,54 A25,25 0 1 1 29,54"
14 android:strokeColor="#4A49A8"
15 android:strokeWidth="8" />
16
17 <!-- Three quarters of the ring, rounded ends. -->
18 <path
19 android:pathData="M54,29 A25,25 0 1 1 29,54"
20 android:strokeColor="#BFC0FF"
21 android:strokeWidth="8"
22 android:strokeLineCap="round" />
23
24 <!-- The centre dot. -->
25 <path
26 android:pathData="M49,54 A5,5 0 1 1 59,54 A5,5 0 1 1 49,54"
27 android:fillColor="#7BE7CF" />
28</vector>The reads as instructions: M29,54 means move to that point, and A25,25 0 1 1 79,54 means draw an arc of radius 25 all the way over to there. Two arcs make a full circle; one arc makes three quarters of one. It is a tiny picture of the ring dial you build in chapter 3.
An attribute value is allowed to sit on the line after its =. That is ordinary XML, and it is how those long pathData strings stay readable on a phone.
The two layers are glued together in res/mipmap-anydpi-v26/ic_launcher.xml:
1<?xml version="1.0" encoding="utf-8"?>
2<adaptive-icon
3 xmlns:android="http://schemas.android.com/apk/res/android">
4 <background
5 android:drawable="@color/ic_launcher_background" />
6 <foreground
7 android:drawable="@drawable/ic_launcher_foreground" />
8 <monochrome
9 android:drawable="@drawable/ic_launcher_foreground" />
10</adaptive-icon>The background is a flat indigo, #1B1B44. The <monochrome> layer is what themed icons on Android 13+ use when the user asks their home screen to match the wallpaper.
Three tabs, one list
Here is the first idea worth slowing down for.
A bottom bar needs three things per tab: a route to navigate to, a label to show, and an icon to draw. Most code keeps those in three places and then quietly lets them drift apart. An keeps them in one.
1package com.nativeworks.focusflow.ui
2
3import androidx.compose.material.icons.Icons
4import androidx.compose.material.icons.filled.BarChart
5import androidx.compose.material.icons.filled.Settings
6import androidx.compose.material.icons.filled.Timer
7import androidx.compose.ui.graphics.vector.ImageVector
8
9// One entry per tab in the bottom bar. Keeping the
10// route string next to the label and the icon means
11// there is exactly one place to edit a tab.
12enum class Destination(
13 val route: String,
14 val label: String,
15 val icon: ImageVector,
16) {
17 TIMER("timer", "Timer", Icons.Filled.Timer),
18 STATS("stats", "Stats", Icons.Filled.BarChart),
19 SETTINGS(
20 "settings",
21 "Settings",
22 Icons.Filled.Settings,
23 ),
24}An enum in Kotlin can carry data. Each of the three entries is a Destination with its , its label and its icon baked in. Adding a fourth tab later means adding one line here — the bar builds itself from Destination.entries.
The bar that remembers nothing
Now the interesting part.
Ask most people to build a tab bar and they reach for a variable: var selectedTab by remember { mutableStateOf(0) }. That works right up until something else changes the screen — a notification tap, a deep link, the back gesture — and now the bar says Timer while the screen says Stats.
The already knows which screen is showing. So the bar asks it.
The bar itself is almost nothing, because it holds nothing:
1@Composable
2private fun FocusBottomBar(
3 current: String,
4 onSelect: (Destination) -> Unit,
5) {
6 NavigationBar {
7 Destination.entries.forEach { dest ->
8 NavigationBarItem(
9 selected = current == dest.route,
10 onClick = { onSelect(dest) },
11 icon = {
12 Icon(
13 imageVector = dest.icon,
14 contentDescription = dest.label,
15 )
16 },
17 label = { Text(dest.label) },
18 )
19 }
20 }
21}selected = current == dest.route is the whole trick. There is no second copy of the truth to keep in step, so it cannot get out of step. This is applied to something as ordinary as a tab bar.
contentDescription = dest.label matters too — that is what a screen reader announces. An icon with no description is a button that says nothing out loud.
Tapping a tab, properly
1// Tapping a tab should never stack a second copy of
2// that screen, and coming back to a tab should show
3// it as you left it.
4private fun openTab(
5 nav: NavHostController,
6 dest: Destination,
7) {
8 nav.navigate(dest.route) {
9 popUpTo(nav.graph.startDestinationId) {
10 saveState = true
11 }
12 launchSingleTop = true
13 restoreState = true
14 }
15}Without those four lines, tapping Timer → Stats → Timer → Stats twenty times builds a stack twenty screens deep, and the back gesture walks you all the way home one screen at a time.
- the start destination clears everything above it first, so the stack never grows past two.
saveState = truefiles away the state of the screen being popped — a scroll position, a half-typed value.- means a tab you are already on does not get a second copy.
restoreState = trueputs the saved state back when you return to that tab.
Together: three tabs that behave the way every tabbed app you have ever used behaves.
Furniture that leaves when it is asked
The three screens have to exist for the to compile, so chapter 1 gives each one a placeholder. They share a single composable:
1package com.nativeworks.focusflow.ui.timer
2
3import androidx.compose.material.icons.Icons
4import androidx.compose.material.icons.filled.Timer
5import androidx.compose.runtime.Composable
6import androidx.compose.ui.Modifier
7import com.nativeworks.focusflow.ui.Placeholder
8
9@Composable
10fun TimerScreen(modifier: Modifier = Modifier) {
11 Placeholder(
12 icon = Icons.Filled.Timer,
13 title = "Timer",
14 message = "The counting clock arrives next.",
15 modifier = modifier,
16 )
17}StatsScreen.kt and SettingsScreen.kt are the same five lines with different words — Icons.Filled.BarChart / "Stats" / "Your finished sessions land here.", and Icons.Filled.Settings / "Settings" / "Session lengths become yours soon.".
ui/Placeholder.kt itself is a centred Column holding an icon, a title and a message. It is temporary: chapter 2 replaces the Timer screen, chapter 5 replaces Settings, chapter 7 replaces Stats and deletes the file.
MainActivity
1package com.nativeworks.focusflow
2
3import android.os.Bundle
4import androidx.activity.ComponentActivity
5import androidx.activity.compose.setContent
6import androidx.activity.enableEdgeToEdge
7import com.nativeworks.focusflow.ui.FocusFlowApp
8import com.nativeworks.focusflow.ui.theme.FocusFlowTheme
9
10class MainActivity : ComponentActivity() {
11 override fun onCreate(saved: Bundle?) {
12 super.onCreate(saved)
13 // Draw behind the status and navigation bars;
14 // Scaffold hands the insets back to us.
15 enableEdgeToEdge()
16 setContent {
17 FocusFlowTheme {
18 FocusFlowApp()
19 }
20 }
21 }
22}Fourteen lines, and it will not change again until chapter 6. enableEdgeToEdge() lets the app paint under the status bar and the gesture bar, which is why Scaffold handing back mattered a moment ago.
End of chapter 1, in dark mode. Three real tabs, three placeholder rooms.
The selected tab gets a 64 × 32 dp rounded pill behind its icon in #005141, with the icon in #A6F2E0 — those are secondaryContainer and onSecondaryContainer from your colour file. Material 3 draws that pill; you only said which item is selected.
- Open Pocket Studio → Projects → New Project.
- Template Empty Compose Activity. Name
Focus Flow, packagecom.nativeworks.focusflow, Minimum SDK API 26, language Kotlin. Tap Create. - Open
app/build.gradle.ktsand add the two new dependency lines —libs.androidx.material.icons.extended(wrapped over three lines) andlibs.androidx.navigation.compose. Tap Sync. - In the project tree, long-press the
uifolder → New → Package, and maketheme,timer,stats,settings. - Create
ui/theme/Color.kt,Type.ktandTheme.kt, thenui/Destination.kt,ui/FocusFlowApp.ktandui/Placeholder.kt, then the three screen files. - Replace the body of
MainActivity.ktwith the version above. - Long-press
res/drawable→ New → Drawable resource file, name itic_launcher_foreground, and paste the vector. - Tap Build, then Run ▶.
- Tap Stats, then Settings, then Timer. Watch the pill move.
- Press Home and look at your new icon. Then flip the phone into dark mode from the quick settings and reopen the app.
Focus Flow — end of chapter 1
A complete project. Unzip it, open it in Pocket Studio, and press Run.
local.properties pointing at a folder that does not exist here.local.properties if there is one — it hard-codes one machine's paths — and let Pocket Studio point Gradle at its own SDK. A project created inside Pocket Studio never needs that file.pluginManagement { repositories { google(); mavenCentral(); gradlePluginPortal() } } block to settings.gradle.kts. It has to be the first thing in the file — Gradle reads it before anything else.<intent-filter> but never says whether other apps are allowed to start it.android:exported="true" to the <activity> tag. It must be true here: the launcher is another app starting yours, and without it nothing can open Focus Flow at all.FocusFlowApp() from ordinary code — almost always straight inside onCreate rather than inside setContent { }.@Composable function or inside a setContent block. Move the call inside setContent { FocusFlowTheme { ... } }.BarChart lives in the extended set.implementation(libs.androidx.material.icons.extended) is in app/build.gradle.kts, then tap Sync and rebuild. Adding a dependency without re-syncing changes nothing at all.- Focus Flow is a timer with three tabs, built over seven chapters.
minSdk 26is chosen so is available without any extra library — chapter 7 needs it.- Every colour is named once in
Color.kt, andFocusFlowThemepicks the light or dark set from the phone's own setting. - The clock's typeface is so the digits never shift as they change.
- A single holds each tab's , label and icon, so
Destination.entriesbuilds the whole bar. - The bar keeps no state: it reads the route off the and compares. One fact, one place.
- , and
restoreStatemake tab taps behave the way people expect. - Next: the timer engine — a , a , and a that counts down without ever freezing the screen.