Project 11: Calculator App with an Expression Parser

Android Development with Kotlin: 21 Real Projects · More Real-World Projects · 16 min read · Updated

Text to answer

  1. The user types the expression with the keypad.
  2. The tokeniser cuts it into numbers and operators.
  3. The first pass multiplies and divides: 3 * 4 becomes 12.
  4. The second pass adds and subtracts: 2 + 12 is 14.

A calculator looks like the easiest app there is, and that is why it makes a good first project of the next group. The screen is simple, but the logic is real: you must turn the text "2+3*4" into the answer 14, not 20. Many beginners reach for a library or a script engine. Here you write a tiny parser yourself, so you learn how text becomes a result, and you keep all of it in a plain Kotlin object that you can test without a phone.

The screen is a grid of buttons built with Row and weight, a display that shows the expression, and a line for the result or an error. The state lives in a ViewModel, as in every project you have built so far, so the calculator keeps its value when the phone rotates.

Concepts in this project

  • Tokenising: splitting "12+3.5*2" into the pieces 12, +, 3.5, *, 2. A leading minus belongs to the first number.
  • Order of operations: the first pass handles * and /, the second handles + and -. This gives the same results as mathematics.
  • Failing safely: the evaluator returns null for broken input (a missing number, letters, division by zero) and the screen shows "Invalid expression" instead of crashing.
  • Separating logic from UI: Calculator is an object with no Android imports. The ViewModel calls it, and unit tests call it directly.
  • Grid layout: Modifier.weight(1f) gives each key equal width, and aspectRatio keeps keys the same shape.

Create the project

  1. New Empty Activity project Calculator, package com.example.calculator.
  2. Add the JUnit test dependency from the block below (no other library is needed).
  3. Add the five files.

`Gradle dependencies for projects 11 to 21 (use the block for this project)`

// ---- Project 11 Calculator: no extra libraries. Test dependency:
testImplementation("junit:junit:4.13.2")

// ---- Project 12 E-commerce
plugins { id("com.google.devtools.ksp"); kotlin("plugin.serialization") }
implementation("com.squareup.retrofit2:retrofit:2.11.0")
implementation("com.squareup.retrofit2:converter-kotlinx-serialization:2.11.0")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3")
implementation("androidx.room:room-runtime:2.6.1")
implementation("androidx.room:room-ktx:2.6.1")
ksp("androidx.room:room-compiler:2.6.1")
implementation("io.coil-kt:coil-compose:2.7.0")
implementation("androidx.compose.material:material-icons-extended")
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.7")

// ---- Project 13 Chat (Firebase)
// project-level plugins: id("com.google.gms.google-services")
implementation(platform("com.google.firebase:firebase-bom:33.7.0"))
implementation("com.google.firebase:firebase-auth")
implementation("com.google.firebase:firebase-firestore")
implementation("com.google.firebase:firebase-messaging")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-play-services:1.9.0")
// Also put google-services.json (from your Firebase console) in the app folder.

// ---- Project 14 Fitness
implementation("androidx.room:room-runtime:2.6.1")
implementation("androidx.room:room-ktx:2.6.1")
ksp("androidx.room:room-compiler:2.6.1")
// No library for the sensor: it is part of Android.

// ---- Project 15 Food delivery
implementation("com.google.maps.android:maps-compose:6.2.1")
implementation("com.google.android.gms:play-services-maps:19.0.0")
// Manifest, inside <application>:
// <meta-data android:name="com.google.android.geo.API_KEY" android:value="YOUR_KEY"/>
// Keep the key out of public git: read it from local.properties with the Secrets Gradle Plugin.

// ---- Project 16 Music player
implementation("androidx.media3:media3-exoplayer:1.5.0")
implementation("androidx.media3:media3-session:1.5.0")
implementation("androidx.media3:media3-ui:1.5.0")

// ---- Project 17 QR scanner
implementation("androidx.camera:camera-core:1.4.1")
implementation("androidx.camera:camera-camera2:1.4.1")
implementation("androidx.camera:camera-lifecycle:1.4.1")
implementation("androidx.camera:camera-view:1.4.1")
implementation("com.google.mlkit:barcode-scanning:17.3.0")

// ---- Project 18 Banking
implementation("androidx.biometric:biometric:1.1.0")
implementation("com.squareup.retrofit2:retrofit:2.11.0")
implementation("com.squareup.retrofit2:converter-kotlinx-serialization:2.11.0")
implementation("com.squareup.okhttp3:okhttp:4.12.0")

// ---- Project 19 AI chatbot
implementation("androidx.room:room-runtime:2.6.1")
implementation("androidx.room:room-ktx:2.6.1")
ksp("androidx.room:room-compiler:2.6.1")
implementation("com.squareup.okhttp3:okhttp:4.12.0")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3")
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.9.0")

// ---- Project 20 Video streaming
implementation("androidx.media3:media3-exoplayer:1.5.0")
implementation("androidx.media3:media3-exoplayer-hls:1.5.0")
implementation("androidx.media3:media3-ui:1.5.0")

// ---- Project 21 Job portal
implementation("com.squareup.retrofit2:retrofit:2.11.0")
implementation("com.squareup.retrofit2:converter-kotlinx-serialization:2.11.0")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3")
Calculator/app/src/main/java/com/example/calculator/
  Calculator.kt           tokenise and evaluate
  CalculatorViewModel.kt  expression, result, error
  CalculatorScreen.kt     display and keypad
  MainActivity.kt
app/src/test/java/com/example/calculator/
  CalculatorTest.kt

The code

`app/src/main/java/com/example/calculator/Calculator.kt`

package com.example.calculator

// Evaluates a plain text expression such as "12+3*4" without using eval.
// Supports + - * / and decimal numbers, with the usual order of operations.
object Calculator {

    fun evaluate(expression: String): Double? {
        val tokens = tokenize(expression) ?: return null
        if (tokens.isEmpty()) return null

        // first pass: * and /
        val afterMulDiv = mutableListOf<String>()
        var i = 0
        while (i < tokens.size) {
            val t = tokens[i]
            if (t == "*" || t == "/") {
                val left = afterMulDiv.removeLastOrNull()?.toDoubleOrNull() ?: return null
                val right = tokens.getOrNull(i + 1)?.toDoubleOrNull() ?: return null
                if (t == "/" && right == 0.0) return null
                afterMulDiv.add((if (t == "*") left * right else left / right).toString())
                i += 2
            } else {
                afterMulDiv.add(t)
                i += 1
            }
        }

        // second pass: + and -
        var result = afterMulDiv.firstOrNull()?.toDoubleOrNull() ?: return null
        var j = 1
        while (j < afterMulDiv.size) {
            val op = afterMulDiv[j]
            val value = afterMulDiv.getOrNull(j + 1)?.toDoubleOrNull() ?: return null
            result = if (op == "+") result + value else result - value
            j += 2
        }
        return result
    }

    private fun tokenize(text: String): List<String>? {
        val tokens = mutableListOf<String>()
        val number = StringBuilder()
        for (ch in text.filter { !it.isWhitespace() }) {
            when {
                ch.isDigit() || ch == '.' -> number.append(ch)
                ch in "+-*/" -> {
                    if (number.isEmpty()) {
                        // a leading minus belongs to the next number
                        if (ch == '-' && tokens.isEmpty()) number.append(ch) else return null
                    } else {
                        tokens.add(number.toString())
                        number.clear()
                        tokens.add(ch.toString())
                    }
                }
                else -> return null
            }
        }
        if (number.isEmpty()) return null
        tokens.add(number.toString())
        return tokens
    }

    fun format(value: Double): String {
        return if (value == value.toLong().toDouble()) value.toLong().toString() else value.toString()
    }
}

`app/src/main/java/com/example/calculator/CalculatorViewModel.kt`

package com.example.calculator

import androidx.lifecycle.ViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update

data class CalcState(
    val expression: String = "",
    val result: String = "",
    val error: Boolean = false,
)

class CalculatorViewModel : ViewModel() {
    private val _state = MutableStateFlow(CalcState())
    val state: StateFlow<CalcState> = _state.asStateFlow()

    fun onKey(key: String) {
        when (key) {
            "C" -> _state.value = CalcState()
            "⌫" -> _state.update { it.copy(expression = it.expression.dropLast(1), error = false) }
            "=" -> equals()
            else -> _state.update { it.copy(expression = it.expression + key, error = false) }
        }
    }

    private fun equals() {
        val current = _state.value.expression
        val value = Calculator.evaluate(current)
        if (value == null) {
            _state.update { it.copy(error = true) }
        } else {
            val text = Calculator.format(value)
            _state.value = CalcState(expression = text, result = text)
        }
    }
}

`app/src/main/java/com/example/calculator/CalculatorScreen.kt`

package com.example.calculator

import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.viewmodel.compose.viewModel

private val rows = listOf(
    listOf("7", "8", "9", "/"),
    listOf("4", "5", "6", "*"),
    listOf("1", "2", "3", "-"),
    listOf("0", ".", "=", "+"),
)

@Composable
fun CalculatorScreen(vm: CalculatorViewModel = viewModel()) {
    val state by vm.state.collectAsState()

    Column(
        modifier = Modifier
            .fillMaxSize()
            .padding(16.dp),
        verticalArrangement = Arrangement.Bottom,
    ) {
        Text(
            text = state.expression.ifEmpty { "0" },
            fontSize = 40.sp,
            textAlign = TextAlign.End,
            modifier = Modifier.fillMaxWidth(),
        )
        Text(
            text = if (state.error) "Invalid expression" else state.result,
            color = if (state.error) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurfaceVariant,
            textAlign = TextAlign.End,
            modifier = Modifier
                .fillMaxWidth()
                .padding(vertical = 8.dp),
        )
        Row(
            modifier = Modifier.fillMaxWidth(),
            horizontalArrangement = Arrangement.spacedBy(8.dp),
        ) {
            KeyButton("C", Modifier.weight(1f), vm::onKey)
            KeyButton("⌫", Modifier.weight(1f), vm::onKey)
        }
        rows.forEach { row ->
            Row(
                modifier = Modifier
                    .fillMaxWidth()
                    .padding(top = 8.dp),
                horizontalArrangement = Arrangement.spacedBy(8.dp),
                verticalAlignment = Alignment.CenterVertically,
            ) {
                row.forEach { key ->
                    KeyButton(key, Modifier.weight(1f), vm::onKey)
                }
            }
        }
    }
}

@Composable
private fun KeyButton(label: String, modifier: Modifier, onKey: (String) -> Unit) {
    Button(
        onClick = { onKey(label) },
        modifier = modifier.aspectRatio(1.3f),
    ) {
        Text(label, fontSize = 24.sp)
    }
}

`app/src/main/java/com/example/calculator/MainActivity.kt`

package com.example.calculator

import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.layout.systemBarsPadding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.ui.Modifier

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        enableEdgeToEdge()
        setContent {
            MaterialTheme {
                Surface(modifier = Modifier.systemBarsPadding()) {
                    CalculatorScreen()
                }
            }
        }
    }
}

`app/src/test/java/com/example/calculator/CalculatorTest.kt`

package com.example.calculator

import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test

class CalculatorTest {
    @Test
    fun multiplicationBeforeAddition() {
        assertEquals(14.0, Calculator.evaluate("2+3*4")!!, 0.0001)
    }

    @Test
    fun decimalsWork() {
        assertEquals(3.75, Calculator.evaluate("7.5/2")!!, 0.0001)
    }

    @Test
    fun leadingMinusIsAllowed() {
        assertEquals(-2.0, Calculator.evaluate("-5+3")!!, 0.0001)
    }

    @Test
    fun divisionByZeroIsInvalid() {
        assertNull(Calculator.evaluate("5/0"))
    }

    @Test
    fun brokenInputIsInvalid() {
        assertNull(Calculator.evaluate("5+"))
        assertNull(Calculator.evaluate("abc"))
    }
}

How it works

  • Each key calls onKey with its label. Digits and operators are appended to the expression. C clears everything, the backspace key removes one character, and = evaluates.
  • evaluate tokenises, then folds multiplication and division, then addition and subtraction. Anything unexpected returns null.
  • After a successful =, the expression becomes the result, so you can keep calculating with it.
  • format prints 14.0 as 14 and keeps 3.75 as it is.

Expected output

The screen shows 0, an empty result line and the keypad. Press 2, +, 3, *, 4, =: the display shows 14. Press C, then 7, ., 5, /, 2, =: the display shows 3.75. Press 5, /, 0, =: the line below the display shows "Invalid expression" in the error colour and the app keeps running. The five unit tests in CalculatorTest pass.

Your task

  1. Build the app and run all the examples above.
  2. Run the unit tests with the green arrow next to the class.
  3. Add a percent key that divides the last number by 100.
  4. Add support for brackets by making the evaluator call itself on the text inside them.

How to check it worked

  • 2+3*4 gives 14.
  • Division by zero shows a message, not a crash.
  • You can explain why the parser uses two passes.

Frequently asked questions

Why not use a script engine to evaluate text?

Running arbitrary text as code is unsafe and heavy. A small parser handles only the characters you allow, so it is safe and easy to test.

Why return null instead of throwing an exception?

Bad input is normal in a calculator. Returning null makes the caller handle it, and the screen shows a message.

Why use Double?

It is fine for a simple calculator. For money, use whole cents or BigDecimal, as in project 7.