Pocket Studio Academy
HomePart 55.12

Focus Flow 1 — navigation skeleton

Full course15 min read·4 questions

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 of it like this

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.

SettingValue
NameFocus Flow
Packagecom.nativeworks.focusflow
Minimum SDKAPI 26
LanguageKotlin

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:

app/build.gradle.ktskts
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 an if statement.

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.

Note

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:

settings.gradle.ktskts
1pluginManagement {
2    repositories {
3        google()
4        mavenCentral()
5        gradlePluginPortal()
6    }
7}

The manifest

One , one , no permissions yet.

app/src/main/AndroidManifest.xmlxml
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:

ui/theme/Color.kt (extract)kotlin
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

ui/theme/Type.ktkotlin
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

ui/theme/Theme.kt (extract)kotlin
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.

res/drawable/ic_launcher_foreground.xmlxml
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:

res/mipmap-anydpi-v26/ic_launcher.xmlxml
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.

ui/Destination.ktkotlin
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:

ui/FocusFlowApp.ktkotlin
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

ui/FocusFlowApp.ktkotlin
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 = true files 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 = true puts 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:

ui/timer/TimerScreen.ktkotlin
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

MainActivity.ktkotlin
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.

9:41▲ ▮
Timer
The counting clock arrives next.
Timer
Stats
Settings

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.

Try it in Pocket Studio
  1. Open Pocket StudioProjectsNew Project.
  2. Template Empty Compose Activity. Name Focus Flow, package com.nativeworks.focusflow, Minimum SDK API 26, language Kotlin. Tap Create.
  3. Open app/build.gradle.kts and add the two new dependency lines — libs.androidx.material.icons.extended (wrapped over three lines) and libs.androidx.navigation.compose. Tap Sync.
  4. In the project tree, long-press the ui folder → NewPackage, and make theme, timer, stats, settings.
  5. Create ui/theme/Color.kt, Type.kt and Theme.kt, then ui/Destination.kt, ui/FocusFlowApp.kt and ui/Placeholder.kt, then the three screen files.
  6. Replace the body of MainActivity.kt with the version above.
  7. Long-press res/drawableNewDrawable resource file, name it ic_launcher_foreground, and paste the vector.
  8. Tap Build, then Run ▶.
  9. Tap Stats, then Settings, then Timer. Watch the pill move.
  10. 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.

Download ZIP
Error Doctor5 common errors
SDK location not found. Define a valid SDK location with an ANDROID_HOME environment variable or by setting the sdk.dir path in your project's local.properties file.
MeansGradle cannot find the Android SDK. On a phone this normally means the project was copied from a computer and carries a local.properties pointing at a folder that does not exist here.
FixDelete 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.
Plugin [id: 'com.android.application', version: '8.7.3', apply: false] was not found in any of the following sources: Gradle Core Plugins, Plugin Repositories
MeansGradle has nowhere to download the Android plugin from, because the repository list is missing.
FixAdd the 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.
Manifest merger failed : android:exported needs to be explicitly specified for element <activity#com.nativeworks.focusflow.MainActivity>. Apps targeting Android 12 and higher are required to specify an explicit value for `android:exported` when the corresponding component has an intent filter defined.
MeansYour launcher activity has an <intent-filter> but never says whether other apps are allowed to start it.
FixAdd 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.
e: file:///.../MainActivity.kt:17:17 @Composable invocations can only happen from the context of a @Composable function
MeansYou called FocusFlowApp() from ordinary code — almost always straight inside onCreate rather than inside setContent { }.
FixEvery composable call must sit inside another @Composable function or inside a setContent block. Move the call inside setContent { FocusFlowTheme { ... } }.
e: file:///.../Destination.kt:12:31 Unresolved reference 'BarChart'.
MeansOnly a small starter set of icons ships in the base Material library. BarChart lives in the extended set.
FixConfirm 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.
Recap
  • Focus Flow is a timer with three tabs, built over seven chapters.
  • minSdk 26 is chosen so is available without any extra library — chapter 7 needs it.
  • Every colour is named once in Color.kt, and FocusFlowTheme picks 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.entries builds the whole bar.
  • The bar keeps no state: it reads the route off the and compares. One fact, one place.
  • , and restoreState make tab taps behave the way people expect.
  • Next: the timer engine — a , a , and a that counts down without ever freezing the screen.