Your assignment: Tip and Bill Splitter
Help a group split a restaurant bill. Accept a bill and people count, choose a tip percentage, and calculate a clear total and share with safe input handling.
Beginner · 2–3 hours of building
Before you begin
- Complete the Student Profile Card or understand saved Compose state.
- Use Kotlin arithmetic, functions, nullable values, and when expressions.
- Understand percentages and basic decimal arithmetic.
By the end, you can…
- Keep editable numeric input as strings.
- Validate numbers before calculating.
- Represent success and error results explicitly.
- Derive results from current inputs instead of storing duplicate state.
- Test calculation rules independently of Android UI.
Required features
- Accept a nonnegative bill amount in US dollars and a positive whole number of people.
- Offer 0%, 10%, 15%, and 20% tip choices and show the current selection.
- Calculate tip, total including tip, and equal share per person.
- Update the result when the bill, people count, or selected tip changes.
- Show helpful messages for blank, malformed, negative, zero-people, nonfinite, and overflowing input.
- Restore all input values and the selected percentage after rotation.
- Format money to two decimal places for display and explain that rounded equal shares may leave a small remainder.
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.
Numeric fields still contain text
While editing 12.50, a student may temporarily leave the field blank or type a decimal point. Keeping bill as a String lets the editor represent these intermediate states. toDoubleOrNull safely attempts conversion; a numeric keyboard is a convenience and cannot guarantee that pasted input is valid.
Validation protects the calculation
A people count of zero must produce a message before division occurs. The calculation also rejects negative bills and nonfinite values such as NaN. These checks define the app's rules, so calculateSplit applies them even if a future screen calls the function without using this text field.
Derived results follow one source of truth
The screen owns bill, people, and tipPercent. A tap on 20% changes tipPercent and the next calculation derives a new total. Storing perPerson in another editable state would create an unnecessary synchronization problem, such as forgetting to update it when the number of people changes.
Calculation precision and display formatting differ
The function uses Double values to calculate a teaching example, and NumberFormat renders USD amounts with two decimals. A total of $10 divided three ways displays $3.33 each, which adds to $9.99. Exact cent allocation and accounting-grade arithmetic are a separate extension rather than silently claiming every displayed split sums exactly.
Three different lifetimes
rememberSaveable restores small supported values after Activity recreation; a ViewModel retains screen state during configuration changes. Neither replaces permanent storage. The notes project uses Room for records that must remain after closing and reopening the app.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. Model the possible calculation outcomes
Your task: Create TipCalculator.kt and define SplitResult with a Success case containing tip, total, and perPerson, plus an Error case containing a message.
Open: app/src/main/java/com/mastercoding/miniprojects/tips/TipCalculator.kt
How it works: The caller should know whether numbers are usable without guessing from a special value such as -1. A sealed result makes that distinction explicit. The UI can handle Success by drawing a summary card and Error by drawing guidance, and Kotlin can check that the when expression covers both.
package com.mastercoding.miniprojects.tips
sealed interface SplitResult {
data class Success(val tip: Double, val total: Double, val perPerson: Double) : SplitResult
data class Error(val message: String) : SplitResult
}
fun calculateSplit(billText: String, peopleText: String, tipPercent: Int): SplitResult {
val bill = billText.trim().toDoubleOrNull()
if (bill == null || !bill.isFinite() || bill < 0.0) {
return SplitResult.Error("Enter a finite bill amount of zero or more.")
}
val people = peopleText.trim().toIntOrNull()
if (people == null || people <= 0) {
return SplitResult.Error("People must be a positive whole number.")
}
if (tipPercent !in 0..100) {
return SplitResult.Error("Tip must be between 0 and 100 percent.")
}
val tip = bill * (tipPercent / 100.0)
val total = bill + tip
if (!tip.isFinite() || !total.isFinite()) {
return SplitResult.Error("This amount is too large to calculate. Try a smaller bill.")
}
return SplitResult.Success(tip, total, total / people)
}Stop and check
Step 2. Validate bill, group size, and percentage
Your task: Write calculateSplit with string bill and people parameters. Trim whitespace, parse safely, reject nonfinite or negative bills, reject nonpositive or fractional people, and validate the tip range before doing arithmetic.
Open: app/src/main/java/com/mastercoding/miniprojects/tips/TipCalculator.kt
How it works: For bill 40 and people 0, the people check returns an Error before division. For a blank bill, toDoubleOrNull returns null instead of throwing. The whole-number parser also rejects 2.5, so the app cannot accidentally split a bill among a fractional number of people.
}
fun calculateSplit(billText: String, peopleText: String, tipPercent: Int): SplitResult {
val bill = billText.trim().toDoubleOrNull()
if (bill == null || !bill.isFinite() || bill < 0.0) {
return SplitResult.Error("Enter a finite bill amount of zero or more.")
}
val people = peopleText.trim().toIntOrNull()
if (people == null || people <= 0) {
return SplitResult.Error("People must be a positive whole number.")
}
if (tipPercent !in 0..100) {
return SplitResult.Error("Tip must be between 0 and 100 percent.")
}
val tip = bill * (tipPercent / 100.0)
val total = bill + tip
if (!tip.isFinite() || !total.isFinite()) {
return SplitResult.Error("This amount is too large to calculate. Try a smaller bill.")
}
return SplitResult.Success(tip, total, total / people)
}Stop and check
Step 3. Implement the percentage and share formulas
Your task: Calculate bill times tipPercent divided by 100.0, add the tip to the bill, then divide that total by the people count. Reject an overflowing total before returning Success.
Open: app/src/main/java/com/mastercoding/miniprojects/tips/TipCalculator.kt
How it works: With bill 100, tip 20, and four people, tip becomes 20, total becomes 120, and each person pays 30. The decimal 100.0 is essential: integer division of 20 by 100 would produce zero. Keep full calculation values until the UI formats them.
return SplitResult.Error("Tip must be between 0 and 100 percent.")
}
val tip = bill * (tipPercent / 100.0)
val total = bill + tip
if (!tip.isFinite() || !total.isFinite()) {
return SplitResult.Error("This amount is too large to calculate. Try a smaller bill.")
}
return SplitResult.Success(tip, total, total / people)
}Stop and check
Step 4. Build the editable form with saved state
Your task: In TipSplitter, create saved bill and people strings and a saved tipPercent integer. Bind the strings to labelled text fields, provide numeric keyboard hints, and call calculateSplit from the current state.
Open: app/src/main/java/com/mastercoding/miniprojects/tips/MainActivity.kt
How it works: A field change updates only its source value. The result is calculated again from all current inputs, so clearing the people field replaces the old share with an error instead of leaving a misleading stale result. rememberSaveable restores the input primitives after activity recreation.
@Composable
fun TipSplitter() {
var bill by rememberSaveable { mutableStateOf("") }
var people by rememberSaveable { mutableStateOf("1") }
var tipPercent by rememberSaveable { mutableIntStateOf(15) }
val result = calculateSplit(bill, people, tipPercent)
Text("Enter a bill in US dollars. Results update whenever an input changes.")
OutlinedTextField(
value = bill,
onValueChange = { bill = it },
label = { Text("Bill amount (USD)") },
supportingText = { Text("Use a dot for decimals, for example 42.50.") },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
OutlinedTextField(
value = people,
onValueChange = { people = it },
label = { Text("Number of people") },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
Text("Tip percentage", style = MaterialTheme.typography.titleMedium)Stop and check
Step 5. Add selectable tip presets
Your task: Render a FilterChip for each of 0, 10, 15, and 20. Set selected by comparing the preset with tipPercent, and assign the preset in onClick.
Open: app/src/main/java/com/mastercoding/miniprojects/tips/MainActivity.kt
How it works: Tapping 20% sends the event that changes tipPercent from 15 to 20. Every chip reads the same value, so the old chip becomes unselected and the new one becomes selected. The calculation reads that value too, updating the monetary summary in the same state flow.
horizontalArrangement = Arrangement.SpaceBetween
) {
listOf(0, 10, 15, 20).forEach { percentage ->
FilterChip(
selected = tipPercent == percentage,
onClick = { tipPercent = percentage },
label = { Text("$percentage%") }
)
}
}
when (result) {
is SplitResult.Error -> Text(result.message, color = MaterialTheme.colorScheme.error)
is SplitResult.Success -> Card(modifier = Modifier.fillMaxWidth()) {
Column(
modifier = Modifier.padding(20.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Text("Tip: ${money(result.tip)}")
Text("Total: ${money(result.total)}")
Text("Each person: ${money(result.perPerson)}", style = MaterialTheme.typography.titleLarge)
}
}
}
Text("Amounts are rounded for display. Equal displayed shares can differ from the total by a few cents.")
}Stop and check
Step 6. Render results and format the currency
Your task: Use when(result) to show either the error message or a Card containing tip, total, and per-person share. Format each displayed amount with the money helper and keep a note about rounding.
Open: app/src/main/java/com/mastercoding/miniprojects/tips/MainActivity.kt
How it works: Formatting belongs at the presentation boundary. calculateSplit returns a number such as 3.333333, while money renders $3.33. The app does not parse its own formatted currency string back into a calculation, and an invalid form cannot continue displaying an old success card.
}
}
when (result) {
is SplitResult.Error -> Text(result.message, color = MaterialTheme.colorScheme.error)
is SplitResult.Success -> Card(modifier = Modifier.fillMaxWidth()) {
Column(
modifier = Modifier.padding(20.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Text("Tip: ${money(result.tip)}")
Text("Total: ${money(result.total)}")
Text("Each person: ${money(result.perPerson)}", style = MaterialTheme.typography.titleLarge)
}
}
}
Text("Amounts are rounded for display. Equal displayed shares can differ from the total by a few cents.")
}
private fun money(amount: Double): String =
NumberFormat.getCurrencyInstance(Locale.US).format(amount)Stop and check
Step 7. Test the rules and recreate the activity
Your task: Run TipCalculatorTest, add one extra case of your own, and then manually enter a bill, change the tip, and rotate the device. Include zero people and an empty field in the manual checks.
Open: app/src/test/java/com/mastercoding/miniprojects/tips/TipCalculatorTest.kt
How it works: The JVM tests call the same pure function as the screen, proving percentage order and validation without launching an emulator. Rotation is a different behavior, so verify it on the device: restoring the inputs should produce the same derived result without saving the result itself.
class TipCalculatorTest {
@Test fun includesTipBeforeSplitting() {
val result = calculateSplit("100", "4", 20) as SplitResult.Success
assertEquals(20.0, result.tip, 0.000001)
assertEquals(120.0, result.total, 0.000001)
assertEquals(30.0, result.perPerson, 0.000001)
}
@Test fun zeroBillAndZeroTipAreValid() {
val result = calculateSplit("0", "2", 0) as SplitResult.Success
assertEquals(0.0, result.perPerson, 0.0)
}
@Test fun rejectsInvalidAmountsAndPeople() {
listOf("", "-1", "hello", "NaN", "Infinity").forEach {
assertTrue(calculateSplit(it, "1", 15) is SplitResult.Error)
}
listOf("", "0", "-2", "1.5", "999999999999").forEach {
assertTrue(calculateSplit("10", it, 15) is SplitResult.Error)
}
}
@Test fun rejectsOverflowAndInvalidTip() {
assertTrue(calculateSplit("1.7e308", "1", 20) is SplitResult.Error)
assertTrue(calculateSplit("10", "1", -1) is SplitResult.Error)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 | State, rules, navigation or persistence 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.
19 complete text files. Copy the selected file with the code panel button, or download the full project above.
package com.mastercoding.miniprojects.tips
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.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.Card
import androidx.compose.material3.FilterChip
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
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.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import java.text.NumberFormat
import java.util.Locale
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
MiniTheme { MiniScreen("Tip and Bill Splitter") { TipSplitter() } }
}
}
}
@Composable
fun TipSplitter() {
var bill by rememberSaveable { mutableStateOf("") }
var people by rememberSaveable { mutableStateOf("1") }
var tipPercent by rememberSaveable { mutableIntStateOf(15) }
val result = calculateSplit(bill, people, tipPercent)
Text("Enter a bill in US dollars. Results update whenever an input changes.")
OutlinedTextField(
value = bill,
onValueChange = { bill = it },
label = { Text("Bill amount (USD)") },
supportingText = { Text("Use a dot for decimals, for example 42.50.") },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
OutlinedTextField(
value = people,
onValueChange = { people = it },
label = { Text("Number of people") },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
Text("Tip percentage", style = MaterialTheme.typography.titleMedium)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
listOf(0, 10, 15, 20).forEach { percentage ->
FilterChip(
selected = tipPercent == percentage,
onClick = { tipPercent = percentage },
label = { Text("$percentage%") }
)
}
}
when (result) {
is SplitResult.Error -> Text(result.message, color = MaterialTheme.colorScheme.error)
is SplitResult.Success -> Card(modifier = Modifier.fillMaxWidth()) {
Column(
modifier = Modifier.padding(20.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Text("Tip: ${money(result.tip)}")
Text("Total: ${money(result.total)}")
Text("Each person: ${money(result.perPerson)}", style = MaterialTheme.typography.titleLarge)
}
}
}
Text("Amounts are rounded for display. Equal displayed shares can differ from the total by a few cents.")
}
private fun money(amount: Double): String =
NumberFormat.getCurrencyInstance(Locale.US).format(amount)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 |
|---|---|
| Enter bill 100, people 4, and select 20%. | Tip is $20.00, total is $120.00, and each person is $30.00. |
| Select 0% with bill 100 and people 4. | Tip becomes $0.00 and each person becomes $25.00. |
| Enter a zero bill with two people. | The calculation succeeds with zero tip, total, and share. |
| Try zero, a negative number, a fraction, or blank text for people. | A positive-whole-number message replaces the result; no division occurs. |
| Try a blank bill, -10, letters, NaN, and an overflowing amount such as 1.7e308 with a 20% tip. | Each invalid value produces guidance without crashing or showing infinity. |
| Enter bill 10, people 3, and select 0%. | Each share displays $3.33 and the rounding note explains the one-cent difference. |
| Enter bill 80, people 2, select 10%, and rotate. | The inputs and 10% selection remain; total is $88.00 and share is $44.00. |
Your completion checklist
Check an item only after demonstrating it. Progress is saved in this browser.
Troubleshooting: understand the cause
The calculated tip is always zero for the presets.
Cause: The percentage is divided using integer arithmetic.
Repair: Divide by 100.0 so the percentage fraction is a Double.
Clearing a field crashes the app.
Cause: toDouble or toInt throws on incomplete text.
Repair: Use nullable parsers and return a validation error before calculating.
The result does not change when the people count changes.
Cause: A previous result was cached or stored as independent state without updating every dependency.
Repair: Derive result from bill, people, and tipPercent on each relevant composition.
Three displayed shares do not exactly match the displayed total.
Cause: Each share is independently rounded to cents.
Repair: Explain the rounding limit; as an extension, allocate integer cents and distribute the remainder explicitly.
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 are bill and people strings in the UI even though the calculation needs numbers?
- Why is perPerson derived instead of saved in its own mutable state?
- How would you change the data model if every displayed person's payment had to add up exactly to the total?
| 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 a custom tip field with the same validation rules.
- Allocate integer cents so the first few people receive any one-cent remainder.
- Add a reset button that restores bill, people, and tip together.
Choose one extension, write its acceptance check first, then implement it without breaking the original scenarios.