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
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.
Follow one tap through Compose
Event → saved state → recomposition → visible result
Emits the click event
Owns the integer count
Reads state and updates affected UI
Shows the count and derived progress
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.
- count
- 3
- goal
- 10
- progress
- 30%
Which value must change to show four taps?
Only count changes from 3 to 4. Goal stays 10; the displayed progress is calculated from those two values.
Connect this frame to the Kotlin source
app/src/main/java/com/mastercoding/phase1/MainActivity.kt
fun CounterScreen(modifier: Modifier = Modifier) {
val goal = 10
var count by rememberSaveable { mutableIntStateOf(0) }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.
- Extract the ZIP. In Android Studio, open the project folder containing
settings.gradle.kts. - Use JDK 17 or 21 and install Android SDK Platform 36 plus the Build Tools requested by Android Studio.
- Allow the first Gradle sync to download dependencies. No backend account or API key is needed.
- Select an emulator or device running API 23 or newer, then run the
appconfiguration. - Start with the expected results below. Open
README.mdandTOOLCHAIN.mdfor the exact project instructions.
.\gradlew.bat :app:assembleDebug :app:testDebugUnitTestKeep the first version small
Implementation roadmap
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.
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.
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
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 = 10Expected behavior
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
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)) {Expected behavior
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
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.")
}
}Expected behavior
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
)
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.")
}
}Expected behavior
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
Text("Reset")
}
Text(if (count == goal) "Goal reached!" else "Keep going — one tap at a time.")
}
}Expected behavior
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
// 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) {Expected behavior
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.
| File | What to explain |
|---|---|
| app/src/main/java/com/mastercoding/phase1/MainActivity.kt | The complete app: activity setup, the saved count, derived UI, and all three button handlers in one short Kotlin file. |
| README.md | Project 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.
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
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.
| Action | Expected 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
| Minutes | Classroom activity |
|---|---|
| 0–10 | Run the finished app first. Ask students to describe its input, state and expected output. |
| 10–30 | Walk through the source files in the solution order. Pause at each named function and predict the next change. |
| 30–50 | Rebuild the main feature together using the guided steps; compare behavior with the complete reference source. |
| 50–65 | Pairs 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
| Criterion | Points | Evidence |
|---|---|---|
| Working core behavior | 40 | Demonstrate the project's acceptance checklist and expected scenarios. |
| Understand the implementation | 30 | Explain the named state owner, event callback and source files without reading a copied script. |
| Edge cases and verification | 20 | Reproduce the listed boundary/error cases and record the observed outcome. |
| Clear classroom demonstration | 10 | Show 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.