Phase 1: Daily Water Counter
Track up to eight glasses of water on one screen. Add a glass, undo one, or reset the count. Practice a single saved UI value and button callbacks; this is a counter exercise, not health advice.
Guided workshop: 1–2 hours
Before you start
- Composable functions and previews
- Column and Row
- State and rememberSaveable
Application structure and responsibilities
- One screen with a single main task.
Concepts to explain: Activity entry point · Material 3 and Scaffold padding · Composable functions · rememberSaveable · Events and recomposition · Derived progress · Simple bounds
What you will practice
- Activity entry point
- Material 3 and Scaffold padding
- Composable functions
- rememberSaveable
- Events and recomposition
- Derived progress
- Simple bounds
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
- 1. Open the project and trace the first screen: The emulator shows Daily Water Counter with a count of 0 and no content hidden behind the system bars.
- 2. Give the screen one source of truth: The first composition displays 0. Later changes to count update the UI without manually changing a Text view.
- 3. Derive the label and progress: At count 3, text reads 3 of 8 and the indicator is approximately 37.5% filled.
- 4. Wire the bounded Add and Subtract events: Subtract is disabled at 0, Add is disabled at 8, and both work between those bounds.
- 5. Reset and explain the completion message: Reaching 8 shows Goal reached! Reset returns the display, progress, and button states to the initial condition.
- 6. Verify restoration and readable feedback: Rotation preserves the count; all actions and messages stay usable with larger text.
Build the app step by step
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. Run and check each small change before moving on.
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 = 8Expected behavior
Step 2: Give the screen one source of truth
Inside CounterScreen, goal is the fixed value 8 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 = 8
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("Daily Water Counter", style = MaterialTheme.typography.headlineLarge)
Text("Track up to $goal glasses. You can subtract or start again.")
Text("$count", style = MaterialTheme.typography.displayLarge)
Text("Progress: $count of $goal glasses")
LinearProgressIndicator(
progress = { count.toFloat() / goal },
modifier = Modifier.fillMaxWidth()
)
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {Expected behavior
Step 3: Derive the label and progress
The number and progress label interpolate count. LinearProgressIndicator reads count.toFloat() / goal, converting 0–8 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 glasses")
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 glass at a time.")
}
}Expected behavior
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 glass at a time.")
}
}Expected behavior
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 glass at a time.")
}
}Expected behavior
Step 6: Verify restoration and readable feedback
Reach 4 and rotate the emulator. The saved count should remain 4. Reach 8 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("Daily Water Counter", style = MaterialTheme.typography.headlineLarge)
Text("Track up to $goal glasses. You can subtract or start again.")
Text("$count", style = MaterialTheme.typography.displayLarge)
Text("Progress: $count of $goal glasses")
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
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 | Purpose |
|---|---|
| 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 build 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 = 8
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("Daily Water Counter", style = MaterialTheme.typography.headlineLarge)
Text("Track up to $goal glasses. You can subtract or start again.")
Text("$count", style = MaterialTheme.typography.displayLarge)
Text("Progress: $count of $goal glasses")
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 glass 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 8, and the progress fraction is 0.25. |
| Reach 8 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.
Assessment rubric
| Criterion | Points | Evidence |
|---|---|---|
| Core behavior | 40 | All acceptance scenarios produce their expected results. |
| Clear state ownership | 30 | Trace one event from its callback to the displayed result and identify the owner of each value. |
| Boundary cases | 20 | Verify the listed invalid, empty, and restoration cases. |
| Readable implementation | 10 | Use descriptive names and small functions; describe any known limitation. |
Total: 100 points. Demonstrate the working app, trace one event through the source, and verify a boundary case.
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 Packing Checklist.
For deeper study, use the official Compose course and Android architecture guide.