Phase 6 · Professional AndroidModule 41~30 min read

Phase 6 Capstone · Tested Tip Calculator

Calculate a tip for one bill using three percentages. A small pure Kotlin calculator, seven deterministic unit tests, and one Compose user-flow test make testing concrete and easy to lecture.

Phase 6: Tested Tip Calculator

Calculate a tip for one bill using three percentages. A small pure Kotlin calculator, seven deterministic unit tests, and one Compose user-flow test make testing concrete and easy to lecture.

Guided workshop: 2–3 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

  • Handling User Input
  • Testing Compose Apps

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: Pure Kotlin business logic · BigDecimal and explicit rounding · Input validation · JUnit tests · Compose UI testing · Small dependency boundaries

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 6 Interactive concept studio

Explain a half-cent before testing the UI

Input → decimal arithmetic → explicit rounding → separate test evidence

Follow this interactionEnter a USD bill of 0.05 and choose 10%. Explain why the tip is 0.01 and the total is 0.06.
01Active now
Input state

Stores the bill string and percentage

02Responsibility
Tip calculator

Validates and calculates using BigDecimal

03Responsibility
JVM unit test

Checks arithmetic without an Android UI

04Responsibility
Compose UI

Displays results and supports UI interaction tests

Input state
calculate("0.05", 10)
Tip calculator

Keep input as text until validation

The text field owns a string, so the student can edit an amount. Selecting 10% changes the integer percentage. The screen calls the same pure calculator function with those two current inputs.

State snapshot
billInput
"0.05"
tipPercent
10
currency
USD

Does a decimal keyboard guarantee that every input is a valid bill?

Connect this frame to the Kotlin source

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

MainActivity.kt
var billInput by rememberSaveable { mutableStateOf("") }
    var tipPercent by rememberSaveable { mutableIntStateOf(15) }
    val result = calculator.calculate(billInput, tipPercent)
A focused source excerpt. The complete file and runnable project are in the source section below.
Open the complete source section →
Misconception: Money calculations can rely on displaying two decimal places at the end.

Define the arithmetic and rounding rule explicitly. This app uses BigDecimal and rounds the tip before adding the total.

Misconception: A unit test verifies that the screen's buttons are connected.

A calculator unit test bypasses Compose. A UI test exercises input, interaction, and displayed output.

Misconception: The animated walkthrough runs JUnit.

It presents a source-based explanation. Run the downloadable project's tests to obtain execution evidence.

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

Tested Tip Calculator: 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

Agree on examples and the rounding rule

50.00 at 20% produces 10.00 and 60.00. A 0.05 bill at 10% produces a 0.01 tip and 0.06 total.

Up nextImplement a pure, validated calculator
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: Agree on examples and the rounding rule

The core accepts a positive USD bill of at most 1000000.00 and a tip of 10, 15, or 20 percent. Input uses a dot and at most two decimal places. Round the tip to cents using HALF_UP, then add that rounded tip to the bill.

Open: app/src/main/java/com/mastercoding/phase6/TipCalculator.kt · app/src/test/java/com/mastercoding/phase6/TipCalculatorTest.kt

TipCalculatorTest.kt

    @Test
    fun fractionalCentRoundsHalfUpBeforeAddingTotal() {
        val result = calculator.calculate("0.05", 10) as TipResult.Success
        assertEquals(BigDecimal("0.01"), result.tip)
        assertEquals(BigDecimal("0.06"), result.total)
    }

    @Test
    fun decimalInputDoesNotUseBinaryFloatingPoint() {
        val result = calculator.calculate("19.99", 15) as TipResult.Success
        assertEquals(BigDecimal("3.00"), result.tip)
        assertEquals(BigDecimal("22.99"), result.total)
    }

    @Test
    fun whitespaceAroundAValidAmountIsAccepted() {
        val result = calculator.calculate("  12.5  ", 10) as TipResult.Success
        assertEquals(BigDecimal("12.50"), result.bill)
Focused excerpt, lines 18–36. Select this file in Complete source code for every line.

Expected behavior

50.00 at 20% produces 10.00 and 60.00. A 0.05 bill at 10% produces a 0.01 tip and 0.06 total.
Pause and explain this step

Have students calculate both examples before opening the UI. The fractional-cent example makes the rounding policy observable rather than accidental.

Step 2: Implement a pure, validated calculator

calculate trims and validates the string before converting it directly to BigDecimal. Invalid values return TipResult.InvalidInput; valid values return bill, tip, and total as TipResult.Success. No Android objects, clock, network, or mutable external state are involved.

Open: app/src/main/java/com/mastercoding/phase6/TipCalculator.kt

TipCalculator.kt

class TipCalculator {
    fun calculate(billInput: String, tipPercent: Int): TipResult {
        val input = billInput.trim()
        if (input.isEmpty()) return TipResult.InvalidInput("Enter a bill amount.")
        if (!Regex("\\d{1,7}(\\.\\d{1,2})?").matches(input)) {
            return TipResult.InvalidInput("Use digits and up to 2 decimal places, for example 12.50.")
        }
        val bill = input.toBigDecimal()
        if (bill <= BigDecimal.ZERO || bill > BigDecimal("1000000.00")) {
            return TipResult.InvalidInput("Enter an amount greater than 0 and no more than 1000000.00.")
        }
        if (tipPercent !in listOf(10, 15, 20)) {
            return TipResult.InvalidInput("Choose 10%, 15%, or 20%.")
        }
        val roundedBill = bill.setScale(2)
        val tip = bill.multiply(BigDecimal(tipPercent))
            .divide(BigDecimal(100))
            .setScale(2, RoundingMode.HALF_UP)
Focused excerpt, lines 10–28. Select this file in Complete source code for every line.

Expected behavior

The same input always gives the same result. Empty, negative, malformed, overly precise, or out-of-range values produce guidance without throwing during parsing.
Pause and explain this step

Trace validation in order, then the arithmetic. Explain why parsing a decimal string preserves its value and why converting from Double would weaken that guarantee.

Step 3: Prove the calculation with local unit tests

TipCalculatorTest checks ordinary values, a half-cent boundary, decimal precision, trimming, invalid input, unsupported percentages, and the maximum amount. Run :app:testDebugUnitTest without an emulator. Exact BigDecimal expectations also verify that displayed money has two decimal places.

Open: app/src/test/java/com/mastercoding/phase6/TipCalculatorTest.kt · app/src/main/java/com/mastercoding/phase6/TipCalculator.kt · README.md

TipCalculatorTest.kt

    @Test
    fun invalidAmountsReturnAValidationResult() {
        listOf("", " ", "hello", "-1", "0", "12.345", "1,50", "NaN", "1000000.01")
            .forEach { input ->
                assertTrue("Expected invalid input: $input", calculator.calculate(input, 15) is TipResult.InvalidInput)
            }
    }

    @Test
    fun unsupportedTipPercentageIsRejected() {
        assertTrue(calculator.calculate("10.00", 99) is TipResult.InvalidInput)
    }

    @Test
    fun largestAllowedBillIsAccepted() {
        val result = calculator.calculate("1000000.00", 20) as TipResult.Success
        assertEquals(BigDecimal("200000.00"), result.tip)
        assertEquals(BigDecimal("1200000.00"), result.total)
Focused excerpt, lines 40–58. Select this file in Complete source code for every line.

Expected behavior

All seven unit tests pass. Temporarily changing HALF_UP to DOWN fails the fractional-cent test; restoring the rule makes it pass again.
Pause and explain this step

Ask what each test protects. Change one business rule briefly to demonstrate a useful failure, then restore it. Avoid testing private implementation details.

Step 4: Connect a small stateful form to the pure function

The Activity supplies one TipCalculator instance to TipCalculatorScreen. rememberSaveable owns bill text and the selected percentage. The screen derives a fresh result on each input change and renders the typed success or validation branch.

Open: app/src/main/java/com/mastercoding/phase6/MainActivity.kt · app/src/main/java/com/mastercoding/phase6/TipCalculator.kt

MainActivity.kt
    var billInput by rememberSaveable { mutableStateOf("") }
    var tipPercent by rememberSaveable { mutableIntStateOf(15) }
    val result = calculator.calculate(billInput, tipPercent)

    Column(
        modifier = modifier.fillMaxSize().imePadding()
            .verticalScroll(rememberScrollState()).padding(20.dp),
        verticalArrangement = Arrangement.spacedBy(16.dp)
    ) {
        Text("Tip Calculator", style = MaterialTheme.typography.headlineMedium)
        Text("Enter a bill in USD. Choose a percentage to see the tip and total.")
        OutlinedTextField(
            value = billInput,
            onValueChange = { billInput = it },
            label = { Text("Bill amount (USD)") },
            singleLine = true,
            keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
            isError = billInput.isNotBlank() && result is TipResult.InvalidInput,
            modifier = Modifier.fillMaxWidth().testTag("billInput")
Focused excerpt, lines 53–71. Select this file in Complete source code for every line.

Expected behavior

Entering 19.99 with 15% shows a 3.00 tip and 22.99 total. Replacing that with invalid text removes the old amounts and shows guidance.
Pause and explain this step

Trace input → state → calculate → result → UI. Explain that this tiny dependency can be supplied directly; Hilt and additional architecture layers are optional later lessons.

Step 5: Add predictable reset and lifecycle behavior

The three chips change only tipPercent. Reset clears billInput and restores the 15% default. Small inputs use rememberSaveable so rotation restores them. Scaffold insets, keyboard padding, and scrolling keep the form reachable.

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

MainActivity.kt
        }
        Button(
            onClick = { billInput = ""; tipPercent = 15 },
            modifier = Modifier.testTag("resetButton")
        ) { Text("Reset") }
        Text("Tips round to the nearest cent using HALF_UP. Use a dot for decimals.")
    }
}
Focused excerpt, lines 98–106. Select this file in Complete source code for every line.

Expected behavior

Reset restores an empty bill, 15%, and Enter a bill amount. Rotation keeps the current text and percentage.
Pause and explain this step

Before pressing Reset, ask students to name every value that should change. Then rotate and explain the distinction between form restoration and permanent storage.

Step 6: Verify the user's complete flow with a Compose test

TipCalculatorUiTest uses createAndroidComposeRule<MainActivity> to launch the app. It locates semantics nodes, enters 50.00, selects 20%, checks both amounts, scrolls to Reset, and verifies the restored default. Run :app:connectedDebugAndroidTest with a device or emulator.

Open: app/src/androidTest/java/com/mastercoding/phase6/TipCalculatorUiTest.kt · app/src/main/java/com/mastercoding/phase6/MainActivity.kt · README.md

TipCalculatorUiTest.kt

    @Test
    fun enteringBillChoosingTipAndResetting() {
        compose.onNodeWithTag("billInput").performTextInput("50.00")
        compose.onNodeWithText("20%").performClick()
        compose.onNodeWithTag("tipResult").assertTextEquals("Tip: USD 10.00")
        compose.onNodeWithTag("totalResult").assertTextEquals("Total: USD 60.00")
        compose.onNodeWithTag("resetButton").performScrollTo().performClick()
        compose.onNodeWithTag("validation").assertTextEquals("Enter a bill amount.")
        compose.onNodeWithText("15%").assertIsSelected()
    }
}
Focused excerpt, lines 17–29. Select this file in Complete source code for every line.

Expected behavior

The UI test proves that the actual screen wires text entry, chip selection, calculation, rendering, and Reset together.
Pause and explain this step

Compare the UI test with a unit test: the former proves wiring, while the latter makes numerical edge cases cheap and precise. Neither needs arbitrary sleeps or a server.

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/phase6/TipCalculator.ktThe complete Android-independent calculation with input checks, a typed result, and a single explicit rounding rule.
app/src/main/java/com/mastercoding/phase6/MainActivity.ktOne Compose form, three percentage chips, calculated results, validation guidance, and Reset.
app/src/test/java/com/mastercoding/phase6/TipCalculatorTest.ktSeven JUnit tests for normal calculation, rounding, precision, spaces, invalid input, invalid percentages, and the bill limit.
app/src/androidTest/java/com/mastercoding/phase6/TipCalculatorUiTest.ktOne Compose test launches the real Activity, enters a bill, selects a percentage, checks both amounts, and resets.
README.mdExact build, install, local-test and emulator-test commands plus lecture checkpoints and manual checks.

One Compose form, three percentage chips, calculated results, validation guidance, and Reset.

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

MainActivity.kt
package com.mastercoding.phase6

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.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.FilterChip
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
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.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp

class MainActivity : ComponentActivity() {
    private val calculator = TipCalculator()

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        enableEdgeToEdge()
        setContent {
            MaterialTheme {
                Scaffold { innerPadding ->
                    TipCalculatorScreen(calculator, Modifier.padding(innerPadding).consumeWindowInsets(innerPadding))
                }
            }
        }
    }
}

@Composable
fun TipCalculatorScreen(calculator: TipCalculator, modifier: Modifier = Modifier) {
    var billInput by rememberSaveable { mutableStateOf("") }
    var tipPercent by rememberSaveable { mutableIntStateOf(15) }
    val result = calculator.calculate(billInput, tipPercent)

    Column(
        modifier = modifier.fillMaxSize().imePadding()
            .verticalScroll(rememberScrollState()).padding(20.dp),
        verticalArrangement = Arrangement.spacedBy(16.dp)
    ) {
        Text("Tip Calculator", style = MaterialTheme.typography.headlineMedium)
        Text("Enter a bill in USD. Choose a percentage to see the tip and total.")
        OutlinedTextField(
            value = billInput,
            onValueChange = { billInput = it },
            label = { Text("Bill amount (USD)") },
            singleLine = true,
            keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
            isError = billInput.isNotBlank() && result is TipResult.InvalidInput,
            modifier = Modifier.fillMaxWidth().testTag("billInput")
        )
        Text("Tip percentage", style = MaterialTheme.typography.titleMedium)
        Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
            listOf(10, 15, 20).forEach { percentage ->
                FilterChip(
                    selected = tipPercent == percentage,
                    onClick = { tipPercent = percentage },
                    label = { Text("$percentage%") }
                )
            }
        }
        when (result) {
            is TipResult.InvalidInput -> Text(
                result.message,
                modifier = Modifier.testTag("validation"),
                color = if (billInput.isBlank()) MaterialTheme.colorScheme.onSurfaceVariant
                    else MaterialTheme.colorScheme.error
            )
            is TipResult.Success -> {
                Text("Tip: USD ${result.tip.toPlainString()}", Modifier.testTag("tipResult"))
                Text(
                    "Total: USD ${result.total.toPlainString()}",
                    modifier = Modifier.testTag("totalResult"),
                    style = MaterialTheme.typography.titleLarge
                )
            }
        }
        Button(
            onClick = { billInput = ""; tipPercent = 15 },
            modifier = Modifier.testTag("resetButton")
        ) { Text("Reset") }
        Text("Tips round to the nearest cent using HALF_UP. Use a dot for decimals.")
    }
}

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
Enter 50.00 and select 20%.Tip: USD 10.00 and Total: USD 60.00.
Enter 19.99 and select 15%.Tip: USD 3.00 and Total: USD 22.99.
Enter 0.05 and select 10%.Tip: USD 0.01 and Total: USD 0.06; the half-cent rounds up.
Paste -1, 12.345, or 1,50 after a valid amount.Validation guidance replaces the old amounts. The app does not crash or show a stale total.
Tap Reset after calculating at 20%.The bill clears, the 15% chip is selected, and Enter a bill amount appears.
Rotate with a valid bill and 20% selected.Bill text, percentage, and the derived result remain.
Run :app:testDebugUnitTest, then :app:connectedDebugAndroidTest with an emulator.Seven local numerical/validation tests and one Compose user-flow test pass; reports are generated under app/build/reports.

Common mistakes and repairs

  • Creating BigDecimal from a Double introduces binary floating-point values. Construct it from the validated input string.
  • Rounding the total instead of the tip can violate the stated rule. Round the tip once, then add it to the bill.
  • A decimal keyboard is a convenience, not validation. Pasted input still needs checks.
  • Do not retain the last successful result after invalid input. Render the current typed result on every input change.
  • Local tests run on the JVM; the Compose test needs an Android device/emulator and the instrumentation runner supplied in the project.
  • A DI framework is not required to pass one calculator into one screen. Add layers only when they clarify a real requirement.

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

Trace validation in order, then the arithmetic. Explain why parsing a decimal string preserves its value and why converting from Double would weaken that guarantee.

calculate trims and validates the string before converting it directly to BigDecimal. Invalid values return TipResult.InvalidInput; valid values return bill, tip, and total as TipResult.Success. No Android objects, clock, network, or mutable external state are involved. Expected result: The same input always gives the same result. Empty, negative, malformed, overly precise, or out-of-range values produce guidance without throwing during parsing.

Ask what each test protects. Change one business rule briefly to demonstrate a useful failure, then restore it. Avoid testing private implementation details.

TipCalculatorTest checks ordinary values, a half-cent boundary, decimal precision, trimming, invalid input, unsupported percentages, and the maximum amount. Run :app:testDebugUnitTest without an emulator. Exact BigDecimal expectations also verify that displayed money has two decimal places. Expected result: All seven unit tests pass. Temporarily changing HALF_UP to DOWN fails the fractional-cent test; restoring the rule makes it pass again.

Trace input → state → calculate → result → UI. Explain that this tiny dependency can be supplied directly; Hilt and additional architecture layers are optional later lessons.

The Activity supplies one TipCalculator instance to TipCalculatorScreen. rememberSaveable owns bill text and the selected percentage. The screen derives a fresh result on each input change and renders the typed success or validation branch. Expected result: Entering 19.99 with 15% shows a 3.00 tip and 22.99 total. Replacing that with invalid text removes the old amounts and shows guidance.

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

  • Add a people count and deterministic tests for splitting the total.
  • Move text into string resources and introduce locale-aware parsing and currency formatting as a separate feature.
  • Add a calculator interface and a fake implementation to demonstrate dependency substitution without a DI framework.

After the intervening lessons, continue with Pocket Tasks.

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