Phase 1 · Getting Started with ComposeProject 6

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.

← 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

Build one screen using layouts, text, buttons, local resources and small saved state values. These projects use no animation APIs, navigation, databases or background work.

Beginner · 1–2 hours of building

The download is a complete reference app. Read the brief, predict the result, then implement each step in a separate working copy. Code runs in Android Studio on a device or emulator; the website displays and explains the source.

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

  1. 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.
  2. 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.
  3. Sync: Allow Gradle to download the pinned dependencies. Initial setup requires internet. The app runs offline and needs no backend, login or API key.
  4. Run: In Device Manager create an emulator with API 23 or newer, or connect a device with USB debugging. Select the app run configuration and press Run.
  5. 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.
  6. 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.
PowerShell — in the project folder
.\gradlew.bat :app:assembleDebug :app:testDebugUnitTest
macOS/Linux: chmod +x gradlew, then ./gradlew :app:assembleDebug :app:testDebugUnitTest. Unit tests run without a device; manual UI checks need a device or emulator.

Keep the toolchain consistent

The projects use the existing course’s pinned toolchain: Kotlin 2.2.21, AGP 8.13.2, Gradle 8.13, and Compose BOM 2025.12.01. This app uses basic Compose UI. Its Gradle files include no animation, navigation, database or ViewModel libraries. All build files and the Gradle wrapper are included. Open 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.

TOPIC 01

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.

TOPIC 02

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.

TOPIC 03

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.

TOPIC 04

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

When a saved state value changes, Compose updates the content that reads it. 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.

MainActivity.kt
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()) {
Reference excerpt · lines 21–46. The full file, with imports, is in Complete source files.

Stop and check

The title, two zero scores, tie message, and Reset both scores button appear.

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.

MainActivity.kt
@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)) {
Reference excerpt · lines 30–55. The full file, with imports, is in Complete source files.

Stop and check

Adding a Home point updates Home only, and rotating preserves both values.

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.

MainActivity.kt
    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()) {
Reference excerpt · lines 32–57. The full file, with imports, is in Complete source files.

Stop and check

The lead message follows the displayed scores after every point change.

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.

MainActivity.kt

@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() } }
}
Reference excerpt · lines 51–72. The full file, with imports, is in Complete source files.

Stop and check

Both cards have consistent spacing, clear action labels, and disabled controls at their boundaries.

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.

MainActivity.kt
        { 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() {
Reference excerpt · lines 44–69. The full file, with imports, is in Complete source files.

Stop and check

Both cards return to zero and the tie message appears.

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.

MainActivity.kt
@Preview(showBackground = true)
@Composable
private fun ScoreboardPreview() {
    MiniTheme { MiniScreen("Two-Team Scoreboard") { Scoreboard() } }
}
Reference excerpt · lines 67–72. The full file, with imports, is in Complete source files.

Stop and check

All labels remain readable and all controls are reachable at a large font size.

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 hereResponsibility
MainActivity.ktThe launcher Activity installs the Compose UI with setContent.
CommonUi.ktMaterial 3 theme and reusable screen container with scrolling and system insets.
Application Kotlin filesLayouts, button callbacks and simple saved state introduced in the guide.
app/src/main/resImages, text resources, and the launch theme.
app/build.gradle.ktsSDK settings and the exact libraries used by this app.
README.md / TOOLCHAIN.mdStudent 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.

MainActivity.kt
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 thisExpected 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

Project acceptance checklist0 / 6 verified

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

  1. Submit the Android Studio project without build caches, .gradle or machine-specific local.properties.
  2. Include two screenshots demonstrating normal behavior and an edge case.
  3. Add a short README recording the device/API version, actions, observed outcomes, and any known limitation.
  4. 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?
CriterionPointsWhat to demonstrate
Core behavior40All required actions give the expected result.
Validation and lifecycle20Invalid inputs, empty states, rotation and the project's persistence rules work.
Interface and accessibility20Readable contrast, labeled inputs, meaningful image descriptions, scrollable content and usable touch targets.
Code and explanation20Small 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.

Further reading