On this page
Tutorials that only show "Hello World" teach you how to press Run, not how to build an app. In this project you'll build a tip and bill-split calculator, small enough to finish in an evening but real enough to cover the core of modern Android: Compose UI, state, input handling, a ViewModel, previews and testing.
You need Android Studio installed (see How to Set Up Android Studio) and basic Kotlin knowledge.
What we'll build
- Enter a bill amount, choose a tip percentage and the number of people.
- See the tip, the total and the amount per person update instantly.
- Invalid input shows a helpful error instead of crashing.
- State survives screen rotation.
Step 1: Create the project
In Android Studio choose New Project โ Empty Activity, name it TipSplit, keep Kotlin and the Kotlin DSL, and finish. Run it once on an emulator or phone to confirm the setup works.
Add the ViewModel-for-Compose dependency to your version catalog and app module (use the latest stable Lifecycle version):
dependencies {
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:<lifecycle-version>")
implementation("androidx.lifecycle:lifecycle-runtime-compose:<lifecycle-version>")
}Step 2: Write the calculation as a pure function
Start with the logic, separate from the UI. A pure function, with no Android and no side effects, is easy to reason about and test.
import java.math.BigDecimal
import java.math.RoundingMode
data class TipResult(val tip: BigDecimal, val total: BigDecimal, val perPerson: BigDecimal)
/** Money uses BigDecimal: Double can't represent values like 0.1 exactly. */
fun calculate(bill: BigDecimal, tipPercent: Int, people: Int): TipResult {
require(people >= 1) { "people must be at least 1" }
val tip = bill.multiply(BigDecimal(tipPercent)).divide(BigDecimal(100), 2, RoundingMode.HALF_UP)
val total = bill.add(tip).setScale(2, RoundingMode.HALF_UP)
val perPerson = total.divide(BigDecimal(people), 2, RoundingMode.HALF_UP)
return TipResult(tip, total, perPerson)
}Step 3: Hold state in a ViewModel
import androidx.lifecycle.ViewModel
import java.math.BigDecimal
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
data class TipUiState(
val billInput: String = "",
val tipPercent: Int = 10,
val peopleInput: String = "1",
val result: TipResult? = null,
val error: String? = null,
)
class TipViewModel : ViewModel() {
private val _uiState = MutableStateFlow(TipUiState())
val uiState: StateFlow<TipUiState> = _uiState.asStateFlow()
fun onBillChange(text: String) = recompute { it.copy(billInput = text) }
fun onTipChange(percent: Int) = recompute { it.copy(tipPercent = percent) }
fun onPeopleChange(text: String) = recompute { it.copy(peopleInput = text) }
private fun recompute(change: (TipUiState) -> TipUiState) = _uiState.update { old ->
val s = change(old)
val bill = s.billInput.toBigDecimalOrNull()
val people = s.peopleInput.toIntOrNull()
when {
s.billInput.isBlank() -> s.copy(result = null, error = null)
bill == null || bill < BigDecimal.ZERO -> s.copy(result = null, error = "Enter a valid amount")
people == null || people < 1 -> s.copy(result = null, error = "People must be 1 or more")
else -> s.copy(result = calculate(bill, s.tipPercent, people), error = null)
}
}
}Step 4: Build the Compose UI
@Composable
fun TipRoute(viewModel: TipViewModel = viewModel()) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
TipScreen(state, viewModel::onBillChange, viewModel::onTipChange, viewModel::onPeopleChange)
}
@Composable
fun TipScreen(
state: TipUiState,
onBillChange: (String) -> Unit,
onTipChange: (Int) -> Unit,
onPeopleChange: (String) -> Unit,
modifier: Modifier = Modifier,
) {
Column(modifier.fillMaxSize().padding(24.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) {
Text("Tip & Split", style = MaterialTheme.typography.headlineMedium)
OutlinedTextField(
value = state.billInput,
onValueChange = onBillChange,
label = { Text("Bill amount (โน)") },
isError = state.error != null,
supportingText = { state.error?.let { Text(it) } },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
Text("Tip: ${state.tipPercent}%")
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
listOf(0, 5, 10, 15, 20).forEach { pct ->
FilterChip(
selected = state.tipPercent == pct,
onClick = { onTipChange(pct) },
label = { Text("$pct%") },
)
}
}
OutlinedTextField(
value = state.peopleInput,
onValueChange = onPeopleChange,
label = { Text("Number of people") },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
state.result?.let { r ->
Card(Modifier.fillMaxWidth()) {
Column(Modifier.padding(16.dp)) {
Text("Tip: โน${r.tip}")
Text("Total: โน${r.total}")
Text("Each person pays โน${r.perPerson}", style = MaterialTheme.typography.titleLarge)
}
}
}
}
}
@Preview(showBackground = true)
@Composable
private fun TipPreview() {
MaterialTheme {
TipScreen(
state = TipUiState(billInput = "1250", tipPercent = 10, peopleInput = "3",
result = calculate(BigDecimal("1250"), 10, 3)),
onBillChange = {}, onTipChange = {}, onPeopleChange = {},
)
}
}Finally, call TipRoute() inside setContent { MaterialTheme { ... } } in MainActivity. Notice that TipScreen is stateless: everything comes from parameters. That's why the preview can show any state you like.
Step 5: Add a unit test
class TipCalculatorTest {
@Test
fun `splits total with tip across people`() {
val r = calculate(BigDecimal("1000"), tipPercent = 10, people = 4)
assertEquals(BigDecimal("100.00"), r.tip)
assertEquals(BigDecimal("1100.00"), r.total)
assertEquals(BigDecimal("275.00"), r.perPerson)
}
@Test
fun `invalid bill shows an error`() {
val vm = TipViewModel()
vm.onBillChange("12abc")
assertEquals("Enter a valid amount", vm.uiState.value.error)
}
}Run the tests from the gutter icon or with ./gradlew testDebugUnitTest. They run on your computer in seconds.
Step 6: Run, rotate, and break it
- Rotate the emulator. Values stay, because the ViewModel survives configuration changes.
- Type letters into the amount field. You should see an error, not a crash.
- Enable dark theme and large font in system settings, and check the layout still works.
Ideas to extend it
Round up per person
Add a switch to round each share up to the nearest rupee. It's a small change to the pure function, plus a new test.
Remember the last tip
Persist the chosen percentage with DataStore so it's restored next launch.
Currency and locale
Format amounts with NumberFormat.getCurrencyInstance() for the user's locale instead of a hard-coded โน.
Troubleshooting
- "Unresolved reference: viewModel" or "collectAsStateWithLifecycle": add the two lifecycle dependencies above and sync Gradle.
- Preview doesn't render: build the project once. Previews need compiled code.
- Values reset on rotation: you're probably creating state with
rememberinside the screen instead of reading it from the ViewModel. - Wrong decimals: make sure you use
BigDecimalwith explicit rounding, notDouble.
Frequently asked questions
Why use a ViewModel for such a small app?
It keeps logic out of the UI, survives rotation and makes testing easy. These habits matter as apps grow, so it's worth practising them early.
Why BigDecimal instead of Double?
Binary floating point can't represent many decimal fractions exactly, which causes rounding surprises with money. BigDecimal with explicit rounding is the safe choice.
Can I publish this app?
You can. Polish it, add an icon and a privacy policy, and follow How to Publish an Android App on Google Play.
Conclusion
You've built a complete first app using the modern Android pattern: a pure calculation function, a ViewModel exposing immutable state, a stateless Compose screen with a preview, and unit tests. The same shape scales to much bigger apps.
Next step: read Common Mistakes Beginners Make in Android Development before your next project.
Official references
๐ฑ Apps built by Yunok Tech
Real Android apps on Google Play, free to download.
Walzi4K wallpapers for lock and home screenGet it โธ
NotiVaultNotification history: recover dismissed notificationsGet it โธ
Ready to build real Android applications?
Reading a roadmap is the easy part. The real progress happens when you build a foundation step by step, with someone reviewing your code and answering the questions that come up along the way.
Move beyond theory and strengthen your Android development skills with Yunoktech's Android course. Learn core concepts, practical implementation, modern Android development techniques, and the reasoning behind building maintainable applications.
Explore the Android course and take your next step as an Android developer.