← Back to Prcatice more · Phase 1
Your assignment: Two-Team Scoreboard
Build a two-team score display with Add, Remove, and Reset buttons. Practice reusable composables, bounded numbers, conditional text, and small saved screen state.
Phase 1 · Simple Compose practice
Beginner · 1–2 hours of building
Before you begin
- Create and run an Empty Activity project in Android Studio.
- Know Kotlin functions, String/Int/Boolean values, if expressions, and basic arithmetic.
- Recognize Text, Button, Column, modifiers, and rememberSaveable from Phase 1.
By the end, you can…
- Build two matching score cards from one composable with parameters.
- Change saved Int state from button callbacks.
- Calculate a lead message from the two scores instead of saving a second result.
- Apply minimum and maximum score rules and verify rotation.
Required features
- Show Home and Away scores, both initially zero.
- Allow each score to increase and decrease independently within 0–99.
- Disable Remove at zero and Add at 99; keep the callback arithmetic within the same bounds.
- Display which team leads and the score difference, or a tie message.
- Reset both scores to zero with one button and restore them after rotation.
- Use one screen, local data, and basic button-driven state without custom animations or advanced app libraries.
Set up the project and run the reference app
- Open: Extract the ZIP. In Android Studio choose Open, then select the project folder containing
settings.gradle.kts. Open that folder rather than just a Kotlin file. - Prepare: Use Android Studio’s bundled JDK 17 or 21 in Gradle settings. In SDK Manager install Android SDK Platform 36 and any Build Tools requested during sync.
- Sync: Allow Gradle to download the pinned dependencies. Initial setup requires internet. The app runs offline and needs no backend, login or API key.
- Run: In Device Manager create an emulator with API 23 or newer, or connect a device with USB debugging. Select the
apprun configuration and press Run. - Observe: Try the normal and boundary scenarios from Test the app. Record what changes after each action. Use Logcat to investigate a crash instead of changing several files at once.
- Practice: Copy the extracted folder to a working folder. Keep the build files, manifest, resources and
CommonUi.kt. Rebuild the application files following the steps below. Attempt each task before consulting its reference.
.\gradlew.bat :app:assembleDebug :app:testDebugUnitTestKeep the toolchain consistent
TOOLCHAIN.md for setup details.Understand the topics before coding
For each topic, connect the Kotlin concept to a visible behavior. Identify the value the screen reads and the event that can change that value.
One state owner
Scoreboard owns home and away. TeamScore receives its current score and callbacks, so both cards share presentation without sharing a score. Passing a callback tells the child what action to request.
Derived feedback
The when expression compares home and away every time this part of the screen is recomposed. At 3–1 the message is Home leads by 2; at 3–3 it becomes The scores are tied. There is no separately saved winner to become outdated.
Boundaries at the action
The UI disables impossible actions, while coerceAtMost and coerceAtLeast also keep the assigned value in range. A counter cannot become negative, even if the callback is invoked when the current value is zero.
Restoration and readable layout
rememberSaveable restores the small selected values when the activity is recreated, such as on rotation. It is temporary screen state rather than permanent app data. MiniScreen provides a scrolling Column; full-width labelled buttons remain reachable when the font is large.
State and screen updates
rememberSaveable can restore small values after Activity recreation, such as rotation. It does not permanently save your choices. Static content needs no mutable state.Build the app step by step
Work in this order. The code panels are focused excerpts from the complete files below. They may begin or end inside a function: use the full source browser when copying a file, including its imports. After every step, run the app and check the stated result.
Step 1. Run the shared screen host
Your task: Open the extracted project in Android Studio and find onCreate. Trace setContent, MiniTheme, MiniScreen, and Scoreboard before changing the screen.
Open: app/src/main/java/com/mastercoding/miniprojects/scoreboard/MainActivity.kt
How it works: The activity hosts the app; MiniScreen supplies scrolling and spacing. The score behavior stays in Scoreboard.
import androidx.compose.ui.unit.dp
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent { MiniTheme { MiniScreen("Two-Team Scoreboard") { Scoreboard() } } }
}
}
@Composable
fun Scoreboard() {
var home by rememberSaveable { mutableStateOf(0) }
var away by rememberSaveable { mutableStateOf(0) }
val result = when {
home > away -> "Home leads by ${home - away}"
away > home -> "Away leads by ${away - home}"
else -> "The scores are tied"
}
Text("Tap a team action to update its score. Each score stays between 0 and 99.")
TeamScore("Home", home, { home = (home + 1).coerceAtMost(99) },
{ home = (home - 1).coerceAtLeast(0) })
TeamScore("Away", away, { away = (away + 1).coerceAtMost(99) },
{ away = (away - 1).coerceAtLeast(0) })
Text(result, style = MaterialTheme.typography.titleLarge)
Button(onClick = { home = 0; away = 0 }, modifier = Modifier.fillMaxWidth()) {Stop and check
Step 2. Keep two independent saved scores
Your task: Declare home and away as separate saved Int values, both starting at zero. Read the same values in the cards and lead message.
Open: app/src/main/java/com/mastercoding/miniprojects/scoreboard/MainActivity.kt
How it works: Changing Home must not accidentally change Away. rememberSaveable supports these small Int values across activity recreation.
@Composable
fun Scoreboard() {
var home by rememberSaveable { mutableStateOf(0) }
var away by rememberSaveable { mutableStateOf(0) }
val result = when {
home > away -> "Home leads by ${home - away}"
away > home -> "Away leads by ${away - home}"
else -> "The scores are tied"
}
Text("Tap a team action to update its score. Each score stays between 0 and 99.")
TeamScore("Home", home, { home = (home + 1).coerceAtMost(99) },
{ home = (home - 1).coerceAtLeast(0) })
TeamScore("Away", away, { away = (away + 1).coerceAtMost(99) },
{ away = (away - 1).coerceAtLeast(0) })
Text(result, style = MaterialTheme.typography.titleLarge)
Button(onClick = { home = 0; away = 0 }, modifier = Modifier.fillMaxWidth()) {
Text("Reset both scores")
}
Text("Scores survive rotation. This practice app does not store match history.")
}
@Composable
private fun TeamScore(team: String, score: Int, addPoint: () -> Unit, removePoint: () -> Unit) {
Card(modifier = Modifier.fillMaxWidth()) {
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {Stop and check
Step 3. Derive the lead message
Your task: Compare home and away with a when expression. Subtract the smaller score from the larger score; use the final branch for a tie.
Open: app/src/main/java/com/mastercoding/miniprojects/scoreboard/MainActivity.kt
How it works: A value that can be calculated from existing state does not need another mutable variable. For Home 3 and Away 1, the difference is 2.
var home by rememberSaveable { mutableStateOf(0) }
var away by rememberSaveable { mutableStateOf(0) }
val result = when {
home > away -> "Home leads by ${home - away}"
away > home -> "Away leads by ${away - home}"
else -> "The scores are tied"
}
Text("Tap a team action to update its score. Each score stays between 0 and 99.")
TeamScore("Home", home, { home = (home + 1).coerceAtMost(99) },
{ home = (home - 1).coerceAtLeast(0) })
TeamScore("Away", away, { away = (away + 1).coerceAtMost(99) },
{ away = (away - 1).coerceAtLeast(0) })
Text(result, style = MaterialTheme.typography.titleLarge)
Button(onClick = { home = 0; away = 0 }, modifier = Modifier.fillMaxWidth()) {
Text("Reset both scores")
}
Text("Scores survive rotation. This practice app does not store match history.")
}
@Composable
private fun TeamScore(team: String, score: Int, addPoint: () -> Unit, removePoint: () -> Unit) {
Card(modifier = Modifier.fillMaxWidth()) {
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text("$team: $score", style = MaterialTheme.typography.headlineSmall)
Button(onClick = addPoint, enabled = score < 99, modifier = Modifier.fillMaxWidth()) {Stop and check
Step 4. Build and reuse the team card
Your task: Create TeamScore with a team name, score, addPoint callback, and removePoint callback. Use full-width buttons with the team name in each label.
Open: app/src/main/java/com/mastercoding/miniprojects/scoreboard/MainActivity.kt
How it works: The component has no private score to drift away from its parameter. Button enabled conditions describe the 0 and 99 limits, and the parent clamps each update.
@Composable
private fun TeamScore(team: String, score: Int, addPoint: () -> Unit, removePoint: () -> Unit) {
Card(modifier = Modifier.fillMaxWidth()) {
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text("$team: $score", style = MaterialTheme.typography.headlineSmall)
Button(onClick = addPoint, enabled = score < 99, modifier = Modifier.fillMaxWidth()) {
Text("Add 1 to $team")
}
Button(onClick = removePoint, enabled = score > 0, modifier = Modifier.fillMaxWidth()) {
Text("Remove 1 from $team")
}
}
}
}
@Preview(showBackground = true)
@Composable
private fun ScoreboardPreview() {
MiniTheme { MiniScreen("Two-Team Scoreboard") { Scoreboard() } }
}Stop and check
Step 5. Reset the whole match
Your task: Set home and away to zero inside Reset both scores. Try this after each team has scored.
Open: app/src/main/java/com/mastercoding/miniprojects/scoreboard/MainActivity.kt
How it works: Reset changes the two source values together. The tie message updates automatically from those values.
{ away = (away - 1).coerceAtLeast(0) })
Text(result, style = MaterialTheme.typography.titleLarge)
Button(onClick = { home = 0; away = 0 }, modifier = Modifier.fillMaxWidth()) {
Text("Reset both scores")
}
Text("Scores survive rotation. This practice app does not store match history.")
}
@Composable
private fun TeamScore(team: String, score: Int, addPoint: () -> Unit, removePoint: () -> Unit) {
Card(modifier = Modifier.fillMaxWidth()) {
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text("$team: $score", style = MaterialTheme.typography.headlineSmall)
Button(onClick = addPoint, enabled = score < 99, modifier = Modifier.fillMaxWidth()) {
Text("Add 1 to $team")
}
Button(onClick = removePoint, enabled = score > 0, modifier = Modifier.fillMaxWidth()) {
Text("Remove 1 from $team")
}
}
}
}
@Preview(showBackground = true)
@Composable
private fun ScoreboardPreview() {Stop and check
Step 6. Preview and check the whole screen
Your task: Open the supplied Preview, then run on a narrow emulator. Raise the font size, scroll to Reset, and rotate with different scores.
Open: app/src/main/java/com/mastercoding/miniprojects/scoreboard/MainActivity.kt
How it works: A preview helps inspect composition; the running app is needed to verify interaction and restoration. The shared scroll container keeps the vertically stacked controls reachable.
@Preview(showBackground = true)
@Composable
private fun ScoreboardPreview() {
MiniTheme { MiniScreen("Two-Team Scoreboard") { Scoreboard() } }
}Stop and check
Complete source files
Every text file in the ZIP is available here, including Kotlin, resources, Gradle scripts and the student README. The binary Gradle wrapper JAR is included in the ZIP. Source excerpts and downloads are generated from these same files.
| Start here | Responsibility |
|---|---|
| MainActivity.kt | The launcher Activity installs the Compose UI with setContent. |
| CommonUi.kt | Material 3 theme and reusable screen container with scrolling and system insets. |
| Application Kotlin files | Layouts, button callbacks and simple saved state introduced in the guide. |
| app/src/main/res | Images, text resources, and the launch theme. |
| app/build.gradle.kts | SDK settings and the exact libraries used by this app. |
| README.md / TOOLCHAIN.md | Student instructions and setup, usable without the website. |
Android launcher Activity, setContent entry point and screen composition. Start tracing the app here.
17 complete text files. Copy the selected file with the code panel button, or download the full project above.
package com.mastercoding.miniprojects.scoreboard
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent { MiniTheme { MiniScreen("Two-Team Scoreboard") { Scoreboard() } } }
}
}
@Composable
fun Scoreboard() {
var home by rememberSaveable { mutableStateOf(0) }
var away by rememberSaveable { mutableStateOf(0) }
val result = when {
home > away -> "Home leads by ${home - away}"
away > home -> "Away leads by ${away - home}"
else -> "The scores are tied"
}
Text("Tap a team action to update its score. Each score stays between 0 and 99.")
TeamScore("Home", home, { home = (home + 1).coerceAtMost(99) },
{ home = (home - 1).coerceAtLeast(0) })
TeamScore("Away", away, { away = (away + 1).coerceAtMost(99) },
{ away = (away - 1).coerceAtLeast(0) })
Text(result, style = MaterialTheme.typography.titleLarge)
Button(onClick = { home = 0; away = 0 }, modifier = Modifier.fillMaxWidth()) {
Text("Reset both scores")
}
Text("Scores survive rotation. This practice app does not store match history.")
}
@Composable
private fun TeamScore(team: String, score: Int, addPoint: () -> Unit, removePoint: () -> Unit) {
Card(modifier = Modifier.fillMaxWidth()) {
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text("$team: $score", style = MaterialTheme.typography.headlineSmall)
Button(onClick = addPoint, enabled = score < 99, modifier = Modifier.fillMaxWidth()) {
Text("Add 1 to $team")
}
Button(onClick = removePoint, enabled = score > 0, modifier = Modifier.fillMaxWidth()) {
Text("Remove 1 from $team")
}
}
}
}
@Preview(showBackground = true)
@Composable
private fun ScoreboardPreview() {
MiniTheme { MiniScreen("Two-Team Scoreboard") { Scoreboard() } }
}Test the app with concrete scenarios
Run these scenarios on your device or emulator. Write down the actual result beside the expected result. A successful Gradle build checks compilation; it does not demonstrate tapping, accessibility, keyboard behavior or rotation.
| Do this | Expected result |
|---|---|
| Launch with a fresh screen. | Home is 0, Away is 0, both Remove buttons are disabled, and the message reports a tie. |
| Add Home three times and Away once. | Home is 3, Away is 1, and the message says Home leads by 2. |
| Add Away twice, then once more. | The message first reports a tie at 3–3, then Away leads by 1 at 3–4. |
| Remove down to zero; separately add a score to 99. | Remove is disabled at 0 and Add is disabled at 99. Neither score leaves 0–99. |
| Rotate with Home 3 and Away 4, then tap Reset both scores. | Rotation restores 3 and 4; Reset returns both to 0 with a tie message. |
| Use a narrow emulator, a large font size, and TalkBack. Scroll through the whole screen. | Labels and results wrap without clipping, controls remain reachable, and each action has a useful spoken text label. |
Your completion checklist
Check an item only after demonstrating it. Progress is saved in this browser.
Troubleshooting: understand the cause
Both team cards change together.
Cause: The two cards read the same state variable.
Repair: Give Home and Away separate saved values and pass the matching callbacks.
The lead message describes an old score.
Cause: The result was saved as independent state or hard-coded.
Repair: Calculate result directly from home and away.
A score becomes negative.
Cause: A decrement is unbounded or enabled at zero.
Repair: Clamp the callback at zero and disable Remove when score is zero.
Values return to their initial selections after rotation.
Cause: Ordinary local variables or remember were used for the screen selections.
Repair: Use rememberSaveable for the small source values; keep calculated results as ordinary val expressions.
Submit your app and explain your decisions
- Submit the Android Studio project without build caches,
.gradleor machine-specificlocal.properties. - Include two screenshots demonstrating normal behavior and an edge case.
- Add a short README recording the device/API version, actions, observed outcomes, and any known limitation.
- Answer the questions below using function and state names from your own implementation.
- Why should TeamScore receive a score instead of declaring its own saved score?
- Trace a Home point from the button callback to the score and lead message.
- Which values are stored state, and which result is calculated?
- How does restoring the current screen after rotation differ from permanently storing it?
| Criterion | Points | What to demonstrate |
|---|---|---|
| Core behavior | 40 | All required actions give the expected result. |
| Validation and lifecycle | 20 | Invalid inputs, empty states, rotation and the project's persistence rules work. |
| Interface and accessibility | 20 | Readable contrast, labeled inputs, meaningful image descriptions, scrollable content and usable touch targets. |
| Code and explanation | 20 | Small named functions, clear state ownership and a correct explanation of one event-to-result trace. |
Extra challenges after the core works
- Add static team subtitles as parameters to TeamScore.
- Add a Reset Home button that changes only the Home state.
Choose one extension, write its acceptance check first, then implement it without breaking the original scenarios.