Phase 1 · Getting Started with ComposeModule 9~30 min read

Phase 1 Capstone · Tap Counter

Build one screen with Add, Subtract, and Reset. A count from 0 to 10 drives the text, button availability, and progress indicator. This small project makes state and event handling visible without introducing storage or networking.

Phase 1: Tap Counter

Build one screen with Add, Subtract, and Reset. A count from 0 to 10 drives the text, button availability, and progress indicator. This small project makes state and event handling visible without introducing storage or networking.

Guided workshop: 1–2 hours

This capstone has a complete reference solution. Run it first, explain one small feature at a time, then let students rebuild it. The core app works offline; advanced additions are optional exercises.

Before you start

  • Your First Composable
  • Layouts: Column, Row & Box
  • State with remember & mutableStateOf

Application structure and responsibilities

  • One screen with one main task. Finish the small working version before adding optional features.
  • All required source files, Gradle configuration, manifest and resources are included in the downloadable project.

Concepts to explain: Activity entry point · Material 3 and Scaffold padding · Composable functions · rememberSaveable · Events and recomposition · Derived progress · Simple bounds

See the concept, then explain the implementation

Follow one concrete interaction through the app. Watch which responsibility is active, inspect the state snapshot, and pause to predict what happens next. Open the Kotlin excerpt to connect each visual frame to the implementation.

Phase 1 Interactive concept studio

Follow one tap through Compose

Event → saved state → recomposition → visible result

Follow this interactionThe student has already tapped Add three times. Follow the fourth tap, then rotate the device.
01Responsibility
Add button

Emits the click event

02Active now
Counter state

Owns the integer count

03Responsibility
Compose

Reads state and updates affected UI

04Responsibility
Screen

Shows the count and derived progress

Counter state
Observe the current stateNo transfer in this frame
State snapshot below

Identify the source of truth

The counter owns one mutable integer. It starts at zero on a fresh launch; this snapshot begins at three after three earlier taps. The goal is a fixed value, so it does not need mutable state.

State snapshot
count
3
goal
10
progress
30%

Which value must change to show four taps?

Connect this frame to the Kotlin source

app/src/main/java/com/mastercoding/phase1/MainActivity.kt

MainActivity.kt
fun CounterScreen(modifier: Modifier = Modifier) {
    val goal = 10
    var count by rememberSaveable { mutableIntStateOf(0) }
A focused source excerpt. The complete file and runnable project are in the source section below.
Open the complete source section →
Misconception: I must change the text widget after each click.

Change count. Compose evaluates the text and progress expressions from that state.

Misconception: A progress bar needs its own mutable percentage.

Here progress is derived from count and goal. Storing it separately creates an unnecessary second source of truth.

Misconception: rememberSaveable is a database.

It restores eligible UI state through Android saved state; it does not provide durable record storage.

Pause, step, or scrub through the interaction. This teaching model explains the included source; it does not execute Android or run tests in the browser. Verify the behavior with the downloaded app.

Download the complete project and run it

Each ZIP includes the application source, build scripts, Gradle wrapper, manifest, resources and README. The full solution is also readable below; no feature is left as an unfinished TODO.

  1. Extract the ZIP. In Android Studio, open the project folder containing settings.gradle.kts.
  2. Use JDK 17 or 21 and install Android SDK Platform 36 plus the Build Tools requested by Android Studio.
  3. Allow the first Gradle sync to download dependencies. No backend account or API key is needed.
  4. Select an emulator or device running API 23 or newer, then run the app configuration.
  5. Start with the expected results below. Open README.md and TOOLCHAIN.md for the exact project instructions.
PowerShell
.\gradlew.bat :app:assembleDebug :app:testDebugUnitTest
Run inside the extracted project. macOS/Linux: chmod +x gradlew, then ./gradlew :app:assembleDebug :app:testDebugUnitTest. UI tests need a running emulator: :app:connectedDebugAndroidTest.

Keep the first version small

Run the reference app, trace one input through the code, and reproduce its behavior before changing it. The short English labels are teaching literals; localization is an optional follow-up exercise.

Implementation roadmap

Android concept labInteractive walkthrough

Tap Counter: build it one step at a time

Follow the implementation in order. Predict the result of each step, then explain the change in your own words.

Lesson sequence1 / 6
Step 1 of 6

Open the project and trace the first screen

The emulator shows Tap Counter with a count of 0 and no content hidden behind the system bars.

Up nextGive the screen one source of truth
A teaching simulation; diagrams illustrate the concept. Run the Kotlin examples in Android Studio.

Step-by-step implementation and lecture solution

Follow these steps in order. Each code window shows the relevant part of a real project file; the complete, unabridged files are in the source browser and ZIP. Pause at the prediction prompt before running the next behavior.

Step 1: Open the project and trace the first screen

Download and extract the complete project. In Android Studio, open the folder containing settings.gradle.kts, wait for Gradle sync, choose an API 23+ device, and run app. Read MainActivity from onCreate to setContent. MaterialTheme provides component styles; Scaffold reports the system-bar padding used by CounterScreen.

Open: app/src/main/java/com/mastercoding/phase1/MainActivity.kt

MainActivity.kt
import androidx.compose.ui.unit.dp

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        enableEdgeToEdge()
        setContent {
            MaterialTheme {
                Scaffold { innerPadding ->
                    CounterScreen(Modifier.padding(innerPadding))
                }
            }
        }
    }
}

@Composable
fun CounterScreen(modifier: Modifier = Modifier) {
    val goal = 10
Focused excerpt, lines 28–46. Select this file in Complete source code for every line.

Expected behavior

The emulator shows Tap Counter with a count of 0 and no content hidden behind the system bars.
Pause and explain this step

Draw the call chain Activity → setContent → MaterialTheme → Scaffold → CounterScreen. Ask students which function owns the screen content.

Step 2: Give the screen one source of truth

Inside CounterScreen, goal is the fixed value 10 and count is mutable Compose state created with rememberSaveable. Reading count during composition connects the visible output to that state. rememberSaveable can restore this small value after rotation or system recreation; it does not create permanent storage.

Open: app/src/main/java/com/mastercoding/phase1/MainActivity.kt

MainActivity.kt
fun CounterScreen(modifier: Modifier = Modifier) {
    val goal = 10
    var count by rememberSaveable { mutableIntStateOf(0) }

    // English labels keep this first project short. Move them to strings.xml to localize.
    Column(
        modifier = modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(24.dp),
        verticalArrangement = Arrangement.spacedBy(20.dp, Alignment.CenterVertically),
        horizontalAlignment = Alignment.CenterHorizontally
    ) {
        Text("Tap Counter", style = MaterialTheme.typography.headlineLarge)
        Text("Reach $goal taps. You can subtract or start again.")
        Text("$count", style = MaterialTheme.typography.displayLarge)
        Text("Progress: $count of $goal taps")
        LinearProgressIndicator(
            progress = { count.toFloat() / goal },
            modifier = Modifier.fillMaxWidth()
        )
        Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
Focused excerpt, lines 45–63. Select this file in Complete source code for every line.

Expected behavior

The first composition displays 0. Later changes to count update the UI without manually changing a Text view.
Pause and explain this step

Write count = 0 on the board. Ask students to predict every visual element that should change when the value becomes 1.

Step 3: Derive the label and progress

The number and progress label interpolate count. LinearProgressIndicator reads count.toFloat() / goal, converting 0–10 into the 0–1 fraction expected by the component. Floating-point conversion avoids integer division. There is no separate mutable progress variable to keep synchronized.

Open: app/src/main/java/com/mastercoding/phase1/MainActivity.kt

MainActivity.kt
        Text("Progress: $count of $goal taps")
        LinearProgressIndicator(
            progress = { count.toFloat() / goal },
            modifier = Modifier.fillMaxWidth()
        )
        Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
            OutlinedButton(onClick = { count -= 1 }, enabled = count > 0) {
                Text("Subtract")
            }
            Button(onClick = { count += 1 }, enabled = count < goal) {
                Text("Add")
            }
        }
        OutlinedButton(onClick = { count = 0 }, enabled = count > 0) {
            Text("Reset")
        }
        Text(if (count == goal) "Goal reached!" else "Keep going — one tap at a time.")
    }
}
Focused excerpt, lines 58–76. Select this file in Complete source code for every line.

Expected behavior

At count 3, text reads 3 of 10 and the indicator is approximately 30% filled.
Pause and explain this step

Calculate 3 / 10 using integer arithmetic, then 3f / 10. Have students explain why the conversion matters.

Step 4: Wire the bounded Add and Subtract events

Add increments count and is enabled only below the goal. Subtract decrements count and is enabled only above zero. The conditions prevent negative numbers and keep the progress fraction in the expected range. Each click writes state; Compose derives the new display.

Open: app/src/main/java/com/mastercoding/phase1/MainActivity.kt

MainActivity.kt
        )
        Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
            OutlinedButton(onClick = { count -= 1 }, enabled = count > 0) {
                Text("Subtract")
            }
            Button(onClick = { count += 1 }, enabled = count < goal) {
                Text("Add")
            }
        }
        OutlinedButton(onClick = { count = 0 }, enabled = count > 0) {
            Text("Reset")
        }
        Text(if (count == goal) "Goal reached!" else "Keep going — one tap at a time.")
    }
}
Focused excerpt, lines 62–77. Select this file in Complete source code for every line.

Expected behavior

Subtract is disabled at 0, Add is disabled at 10, and both work between those bounds.
Pause and explain this step

Trace 0 → 1 → 2 → 1. Before each tap, ask which buttons are enabled and why.

Step 5: Reset and explain the completion message

Reset assigns 0 to count. The final Text chooses its message from count == goal, so success cannot disagree with the visible number. Reset is disabled when already at zero, giving students another example of deriving UI behavior from one value.

Open: app/src/main/java/com/mastercoding/phase1/MainActivity.kt

MainActivity.kt
            Text("Reset")
        }
        Text(if (count == goal) "Goal reached!" else "Keep going — one tap at a time.")
    }
}
Focused excerpt, lines 72–77. Select this file in Complete source code for every line.

Expected behavior

Reaching 10 shows Goal reached! Reset returns the display, progress, and button states to the initial condition.
Pause and explain this step

Ask why a second mutable hasReachedGoal flag would introduce an unnecessary chance of disagreement.

Step 6: Verify restoration and readable feedback

Reach 4 and rotate the emulator. The saved count should remain 4. Reach 10 and reset. Increase font size and confirm the scrollable content remains reachable. The progress text communicates the same meaning as the bar. English labels are intentionally inline; moving them to string resources is a localization extension.

Open: app/src/main/java/com/mastercoding/phase1/MainActivity.kt · README.md

MainActivity.kt
    // English labels keep this first project short. Move them to strings.xml to localize.
    Column(
        modifier = modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(24.dp),
        verticalArrangement = Arrangement.spacedBy(20.dp, Alignment.CenterVertically),
        horizontalAlignment = Alignment.CenterHorizontally
    ) {
        Text("Tap Counter", style = MaterialTheme.typography.headlineLarge)
        Text("Reach $goal taps. You can subtract or start again.")
        Text("$count", style = MaterialTheme.typography.displayLarge)
        Text("Progress: $count of $goal taps")
        LinearProgressIndicator(
            progress = { count.toFloat() / goal },
            modifier = Modifier.fillMaxWidth()
        )
        Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
            OutlinedButton(onClick = { count -= 1 }, enabled = count > 0) {
                Text("Subtract")
            }
            Button(onClick = { count += 1 }, enabled = count < goal) {
Focused excerpt, lines 49–67. Select this file in Complete source code for every line.

Expected behavior

Rotation preserves the count; all actions and messages stay usable with larger text.
Pause and explain this step

Have one student operate the app and another explain the state transition before each action.

Complete source code — every file

Start with MainActivity.kt, then follow the file guide. The selector also contains the tests, manifest, resources and complete Gradle configuration. The binary Gradle wrapper JAR is included in the download.

FileWhat to explain
app/src/main/java/com/mastercoding/phase1/MainActivity.ktThe complete app: activity setup, the saved count, derived UI, and all three button handlers in one short Kotlin file.
README.mdProject opening instructions, a lecture sequence, expected behavior, and small extension exercises.

The complete app: activity setup, the saved count, derived UI, and all three button handlers in one short Kotlin file.

15 complete text files. Copy the selected file with the code panel button, or download the full project above.

MainActivity.kt
package com.mastercoding.phase1

import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        enableEdgeToEdge()
        setContent {
            MaterialTheme {
                Scaffold { innerPadding ->
                    CounterScreen(Modifier.padding(innerPadding))
                }
            }
        }
    }
}

@Composable
fun CounterScreen(modifier: Modifier = Modifier) {
    val goal = 10
    var count by rememberSaveable { mutableIntStateOf(0) }

    // English labels keep this first project short. Move them to strings.xml to localize.
    Column(
        modifier = modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(24.dp),
        verticalArrangement = Arrangement.spacedBy(20.dp, Alignment.CenterVertically),
        horizontalAlignment = Alignment.CenterHorizontally
    ) {
        Text("Tap Counter", style = MaterialTheme.typography.headlineLarge)
        Text("Reach $goal taps. You can subtract or start again.")
        Text("$count", style = MaterialTheme.typography.displayLarge)
        Text("Progress: $count of $goal taps")
        LinearProgressIndicator(
            progress = { count.toFloat() / goal },
            modifier = Modifier.fillMaxWidth()
        )
        Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
            OutlinedButton(onClick = { count -= 1 }, enabled = count > 0) {
                Text("Subtract")
            }
            Button(onClick = { count += 1 }, enabled = count < goal) {
                Text("Add")
            }
        }
        OutlinedButton(onClick = { count = 0 }, enabled = count > 0) {
            Text("Reset")
        }
        Text(if (count == goal) "Goal reached!" else "Keep going — one tap at a time.")
    }
}

Definition of done

Project acceptance checklist0 / 5 verified

Check an item only after demonstrating it. Progress is saved in this browser.

Try the reference solution yourself

Run each action and compare the result. Record the device, action and observed outcome. For the testing phase, run the included unit tests and the Compose test separately: a JVM test does not prove on-device UI behavior.

ActionExpected result
Launch the app and try Subtract.Count remains 0 because Subtract is disabled; Reset is also disabled.
Tap Add three times, then Subtract once.Count reads 2, the label reads 2 of 10, and the progress fraction is 0.2.
Reach 10 and try Add.Add is disabled and Goal reached! is visible.
Rotate while the count is 4.The recreated activity restores 4 through rememberSaveable.
Tap Reset after increasing the count.Count and progress return to 0, with Subtract and Reset disabled.

Common mistakes and repairs

  • Using count / goal performs integer division; convert count to Float first.
  • A plain local variable is recreated during composition and is not observable Compose state.
  • A separate mutable progress or success flag can disagree with the count; derive both instead.
  • rememberSaveable is small UI-state restoration, not a permanent record of completed practice.

Instructor lecture plan

MinutesClassroom activity
0–10Run the finished app first. Ask students to describe its input, state and expected output.
10–30Walk through the source files in the solution order. Pause at each named function and predict the next change.
30–50Rebuild the main feature together using the guided steps; compare behavior with the complete reference source.
50–65Pairs reproduce an edge case and explain one callback or data-flow path to the class.

Instructor discussion and answer notes

Write count = 0 on the board. Ask students to predict every visual element that should change when the value becomes 1.

Inside CounterScreen, goal is the fixed value 10 and count is mutable Compose state created with rememberSaveable. Reading count during composition connects the visible output to that state. rememberSaveable can restore this small value after rotation or system recreation; it does not create permanent storage. Expected result: The first composition displays 0. Later changes to count update the UI without manually changing a Text view.

Calculate 3 / 10 using integer arithmetic, then 3f / 10. Have students explain why the conversion matters.

The number and progress label interpolate count. LinearProgressIndicator reads count.toFloat() / goal, converting 0–10 into the 0–1 fraction expected by the component. Floating-point conversion avoids integer division. There is no separate mutable progress variable to keep synchronized. Expected result: At count 3, text reads 3 of 10 and the indicator is approximately 30% filled.

Trace 0 → 1 → 2 → 1. Before each tap, ask which buttons are enabled and why.

Add increments count and is enabled only below the goal. Subtract decrements count and is enabled only above zero. The conditions prevent negative numbers and keep the progress fraction in the expected range. Each click writes state; Compose derives the new display. Expected result: Subtract is disabled at 0, Add is disabled at 10, and both work between those bounds.

Assessment rubric

CriterionPointsEvidence
Working core behavior40Demonstrate the project's acceptance checklist and expected scenarios.
Understand the implementation30Explain the named state owner, event callback and source files without reading a copied script.
Edge cases and verification20Reproduce the listed boundary/error cases and record the observed outcome.
Clear classroom demonstration10Show the app, trace one action and describe one small extension.

Total: 100 points. Ask students to show the working app, explain one complete event/data path, reproduce an edge case, and make one small change themselves. Keep any limitations explicit.

Optional extensions after the core works

  • Change the goal to 20 and predict the progress fraction.
  • Move English text into formatted Android string resources.
  • Extract a bounded increment function and discuss its inputs and output.

After the intervening lessons, continue with Shopping List.

For deeper study, use the official Compose course and Android architecture guide.