Best Tutorial for Android Jetpack Compose

Android App Development

Stay ahead with the latest tools, trends, and best practices in Android development

Scientific Calculator in Jetpack Compose

Scientific Calculator in Jetpack Compose  - Coding Bihar
Scientific Calculator in Jetpack Compose

Scientific Calculator in Jetpack Compose

1. Project overview (what & why)

I built a fully-featured scientific calculator using Jetpack Compose with zero third-party libraries. The goal was not just to reproduce a handheld calculator, but to explore modern UI paradigms, a small expression-evaluation engine, and practical UX choices (DEG/RAD modes, live preview, typed vs. committed results). This project is ideal for Android developers learning Compose, parsing/evaluation basics, and edge-case handling in numeric UIs.

Core features

  • Arithmetic (+ - * / ^ %) and parentheses
  • Functions: sin, cos, tan, asin, acos, atan, ln, log, sqrt
  • Constants: π
  • Factorial, percent, square (x²), cube (x³) and general power (^)

DEG/RAD toggle for trig functions

  • Live preview while typing + final result only when = pressed
  • Nicely formatted results (no trailing .0, 6-decimal rounding)

2. Requirements & constraints

Functional

  • Accurate math for common scientific operations.
  • Immediate visual feedback but no confusing automatic commitment of results.
  • Clear DEG/RAD handling so users don’t get unexpected trig answers.

Non-functional

  • No third-party libraries — keep dependencies minimal.
  • Compose-first UI; single-file reference implementation for learning and reuse.
  • Reasonable performance on low-end devices.

Constraints

  • Numeric precision limited to Double (Kotlin Double) and BigDecimal used only for presentation rounding.
  • Factorial implemented only for non-negative integers.
  • No symbolic algebra — evaluator computes numeric results only.

3. Design decisions & rationale

Jetpack Compose

Compose simplifies UI state management: expression, preview, final result, degree mode are all MutableState. LaunchedEffect drives live-preview evaluation which keeps UI responsive and declarative.

A 3-column layout felt balanced for phone screens and easier to press.

Expression engine

  • Implemented tokenizer → shunting-yard (infix → RPN) → RPN evaluator. Rationale:
  • Shunting-yard is compact, deterministic, and handles operator precedence & functions well.
  • RPN evaluation is straightforward and easy to extend (add functions/operators).
  • Chose to keep everything numeric (no AST transformations), avoiding the complexity of a full parser generator.

DEG/RAD handling

  • Trig functions (sin, cos, tan) convert arguments when DEG is enabled (Math.toRadians), inverse trig converts results back to degrees.
  • This prevents user confusion (e.g., sin(30) → 0.5 in DEG mode).

UX: live preview + commit

  • Live preview provides immediate feedback while typing (helpful to catch obvious mistakes).
  • Committing only on = avoids accidental overwrites and mirrors real calculators’ behavior.
  • Preview displayed in lighter color and final result shown prominently when committed.

Button labeling vs expression

Buttons display human-friendly symbols (×, ÷, x², √, π) but inject machine-friendly tokens into the expression (*, /, ^2, sqrt(, pi). This keeps the evaluator simple while the UI remains readable.

4. Implementation highlights (code patterns)

  • State: expr, preview, result, error, degreeMode — all remembered.
  • Live preview: LaunchedEffect(expr, degreeMode) { ... } attempts to evaluate and sets preview.
  • Commit: onButton("=") { result = preview }
  • Formatting: If r % 1.0 == 0.0 show integer; else use BigDecimal(r).setScale(6, HALF_UP).stripTrailingZeros().toPlainString().
  • Safety: Parser throws descriptive exceptions for mismatched parentheses, unknown tokens, and invalid factorial inputs.

Final Code:


data class CalcButton(val display: String, val value: String)

@Composable
fun ScientificCalculator() {
    var expr by remember { mutableStateOf("") }
    var preview by remember { mutableStateOf("") } // live preview
    var result by remember { mutableStateOf("") }  // final result after '='
    var error by remember { mutableStateOf(null) }
    var degreeMode by remember { mutableStateOf(true) }

    // Live preview
    LaunchedEffect(expr, degreeMode) {
        if (expr.isNotEmpty()) {
            try {
                val r = evaluateExpression(expr, degreeMode)
                preview = if (r % 1.0 == 0.0) {
                    r.toLong().toString()
                } else {
                    BigDecimal(r).setScale(6, RoundingMode.HALF_UP)
                        .stripTrailingZeros().toPlainString()
                }
                error = null
            } catch (_: Exception) {
                preview = ""
            }
        } else {
            preview = ""
        }
    }

    val buttons = listOf(
        listOf(CalcButton("sin", "sin("), CalcButton("cos", "cos("), CalcButton("tan", "tan(")),
        listOf( CalcButton("sin⁻¹", "asin("), CalcButton("cos⁻¹", "acos("), CalcButton("tan⁻¹", "atan(")),
        listOf( CalcButton("7", "7"),CalcButton("8", "8"), CalcButton("9", "9"), CalcButton("÷", "/") ),
        listOf( CalcButton("4", "4"),  CalcButton("5", "5"), CalcButton("6", "6"), CalcButton("×", "*") ),
        listOf(CalcButton("0", "0"), CalcButton("1", "1"), CalcButton("2", "2"), CalcButton("3", "3")),
        listOf(CalcButton("x²", "^"), CalcButton("√", "sqrt("), CalcButton(".", "."),  CalcButton("−", "-")),
        listOf(CalcButton("(", "("), CalcButton(")", ")"), CalcButton("π", "pi"), CalcButton("+", "+")),
        listOf(CalcButton("C", "C"), CalcButton("⌫", "⌫"), CalcButton("%", "%"), CalcButton("=", "="))
    )

    Column(modifier = Modifier.systemBarsPadding().fillMaxSize()) {

        Row(
            modifier = Modifier.fillMaxWidth(),
            horizontalArrangement = Arrangement.SpaceBetween,
            verticalAlignment = Alignment.CenterVertically
        ) {
            Text("CB Scientific Calculator", style = MaterialTheme.typography.titleLarge, color = MaterialTheme.colorScheme.primary)
            Text(if (degreeMode) "DEG" else "RAD", fontWeight = FontWeight.Bold)
            Switch(checked = degreeMode, onCheckedChange = { degreeMode = it })
        }

        // Display
        Column(
            modifier = Modifier
                .fillMaxWidth()
                .weight(0.15f)
                .padding(8.dp)
        ) {

            Text(text = expr.ifEmpty { "0" }, fontSize = 28.sp, modifier = Modifier.align(Alignment.End))
            if (preview.isNotEmpty()) {
                Text(text = preview, fontSize = 22.sp, color = MaterialTheme.colorScheme.primary, modifier = Modifier.align(Alignment.End))
            }
            if (result.isNotEmpty()) {
                Text(text = "= $result", fontSize = 22.sp, fontWeight = FontWeight.Bold, modifier = Modifier.align(Alignment.End))
            }

        }

        // Buttons Grid
        Column(modifier = Modifier.fillMaxWidth().weight(0.85f).padding(6.dp),

            verticalArrangement = Arrangement.Bottom) {
            for (row in buttons) {
                Row(modifier = Modifier.fillMaxWidth().padding(8.dp), horizontalArrangement = Arrangement.spacedBy(4.dp)) {
                    for (btn in row) {
                        CalculatorButton(text = btn.display, modifier = Modifier.weight(1f)) {
                            when (btn.value) {
                                "C" -> { expr = ""; result = ""; preview = ""; error = null }
                                "⌫" -> if (expr.isNotEmpty()) expr = expr.dropLast(1)
                                "=" -> result = preview
                                else -> expr += btn.value
                            }
                        }
                    }
                }
            }
        }
    }
}

@Composable
fun CalculatorButton(text: String, modifier: Modifier = Modifier, onClick: () -> Unit) {
    Button(onClick = onClick, modifier = modifier.height(56.dp)) {
        Text(text = text, fontSize = 18.sp)
    }
}

// ---------- Expression evaluation ----------

private val operators = mapOf(
    "+" to Operator(1, Associativity.LEFT),
    "-" to Operator(1, Associativity.LEFT),
    "*" to Operator(2, Associativity.LEFT),
    "/" to Operator(2, Associativity.LEFT),
    "^" to Operator(3, Associativity.RIGHT),
    "%" to Operator(2, Associativity.LEFT),
    "!" to Operator(4, Associativity.LEFT, unary = true)
)

private data class Operator(val prec: Int, val assoc: Associativity, val unary: Boolean = false)
private enum class Associativity { LEFT, RIGHT }

private fun tokenize(s: String): List {
    val tokens = mutableListOf()
    var i = 0
    while (i < s.length) {
        val c = s[i]
        when {
            c.isWhitespace() -> i++
            c.isDigit() || c == '.' -> {
                val start = i
                while (i < s.length && (s[i].isDigit() || s[i] == '.')) i++
                tokens += s.substring(start, i)
            }
            c.isLetter() -> {
                val start = i
                while (i < s.length && s[i].isLetter()) i++
                tokens += s.substring(start, i)
            }
            else -> { tokens += c.toString(); i++ }
        }
    }
    return tokens
}

private fun isFunction(token: String) = token in listOf("sin","cos","tan","asin","acos","atan","log","ln","sqrt")

@Throws(Exception::class)
private fun infixToRPN(tokens: List): List {
    val out = mutableListOf()
    val stack = ArrayDeque()
    for (token in tokens) {
        when {
            token.toDoubleOrNull() != null -> out += token
            token == "pi" -> out += Math.PI.toString()
            isFunction(token) -> stack.addFirst(token)
            operators.containsKey(token) -> {
                val o1 = token
                while (stack.isNotEmpty() && (operators.containsKey(stack.first()) || isFunction(stack.first()))) {
                    val o2 = stack.first()
                    if (operators.containsKey(o2)) {
                        val op1 = operators[o1]!!
                        val op2 = operators[o2]!!
                        val cond = (op1.assoc == Associativity.LEFT && op1.prec <= op2.prec) ||
                                (op1.assoc == Associativity.RIGHT && op1.prec < op2.prec)
                        if (cond) out += stack.removeFirst() else break
                    } else out += stack.removeFirst()
                }
                stack.addFirst(o1)
            }
            token == "(" -> stack.addFirst(token)
            token == ")" -> {
                while (stack.isNotEmpty() && stack.first() != "(") out += stack.removeFirst()
                if (stack.isEmpty()) throw Exception("Mismatched parentheses")
                stack.removeFirst()
                if (stack.isNotEmpty() && isFunction(stack.first())) out += stack.removeFirst()
            }
            else -> throw Exception("Unknown token: $token")
        }
    }
    while (stack.isNotEmpty()) {
        val t = stack.removeFirst()
        if (t == "(" || t == ")") throw Exception("Mismatched parentheses")
        out += t
    }
    return out
}

@Throws(Exception::class)
private fun evaluateRPN(rpn: List, degreeMode: Boolean): Double {
    val stack = ArrayDeque()
    for (token in rpn) {
        when {
            token.toDoubleOrNull() != null -> stack.addFirst(token.toDouble())
            token == "+" -> binaryOp(stack) { a, b -> a + b }
            token == "-" -> binaryOp(stack) { a, b -> a - b }
            token == "*" -> binaryOp(stack) { a, b -> a * b }
            token == "/" -> binaryOp(stack) { a, b -> a / b }
            token == "^" -> binaryOp(stack) { a, b -> a.pow(b) }
            token == "%" -> binaryOp(stack) { a, b -> a % b }
            token == "!" -> {
                if (stack.isEmpty()) throw Exception("Insufficient operands for !")
                val v = stack.removeFirst()
                stack.addFirst(factorial(v))
            }
            isFunction(token) -> {
                if (stack.isEmpty()) throw Exception("Insufficient operands for $token")
                val v = stack.removeFirst()
                val arg = if (degreeMode) Math.toRadians(v) else v
                val res = when (token) {
                    "sin" -> sin(arg)
                    "cos" -> cos(arg)
                    "tan" -> tan(arg)
                    "asin" -> if (degreeMode) Math.toDegrees(asin(v)) else asin(v)
                    "acos" -> if (degreeMode) Math.toDegrees(acos(v)) else acos(v)
                    "atan" -> if (degreeMode) Math.toDegrees(atan(v)) else atan(v)
                    "ln" -> ln(v)
                    "log" -> log10(v)
                    "sqrt" -> sqrt(v)
                    else -> throw Exception("Unknown function $token")
                }
                stack.addFirst(res)
            }
            else -> throw Exception("Unknown token: $token")
        }
    }
    if (stack.size != 1) throw Exception("Invalid expression")
    return stack.first()
}

private fun binaryOp(stack: ArrayDeque, op: (Double, Double) -> Double) {
    if (stack.size < 2) throw Exception("Insufficient operands")
    val b = stack.removeFirst()
    val a = stack.removeFirst()
    stack.addFirst(op(a, b))
}

private fun factorial(x: Double): Double {
    val n = x.toInt()
    if (n < 0 || n.toDouble() != x) throw Exception("Factorial only for non-negative integers")
    var res = 1.0
    for (i in 1..n) res *= i
    return res
}

@Throws(Exception::class)
fun evaluateExpression(input: String, degreeMode: Boolean): Double {
    val cleaned = input.replace("π","pi")
    val tokens = tokenize(cleaned)
    val rpn = infixToRPN(tokens)
    return evaluateRPN(rpn, degreeMode)
}

MainActivity

class MainActivity : ComponentActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        enableEdgeToEdge()
        setContent {
            CBCalculatorTheme {
                
                ScientificCalculator()
                
            }
        }
    }
}

output:

Scientific Calculator in Jetpack Compose Screen shot dark modeScientific Calculator in Jetpack Compose  Screenshot Screenshot light mode


5. Testing & validation

Unit tests (recommended)

  • Tokenizer tests: ensure "sin(30)+2.5" tokenizes correctly.
  • Shunting-yard tests: ensure operator precedence 2+3*4 → 2 3 4 * +.
  • RPN evaluation tests: numeric checks including sin(30) in both DEG and RAD modes.
  • Edge cases: factorial of 5.0, 5.1 (should error), mismatched parentheses errors.


Manual tests

  • Typical operations: 2+2, 10/3, sqrt(16).
  • Trig checks: sin(30) in DEG gives 0.5, in RAD gives -0.988....
  • Inverses: sin(0.5) then asin(0.5) roundtrips (with degree conversion if DEG).

6. Known limitations & trade-offs

  • Uses Double for computation — precision and rounding behaviors are those of floating point. For ultra-high precision (scientific contexts) a bigger numeric library would be needed.
  • Factorial limited to integer inputs. Extending to Gamma function requires additional math libraries or own implementation.
  • No expression editing cursor — appends/backspaces at the end only. Inserting or editing in middle would need a richer editor component.

7. Future improvements

  • Add expression cursor and selection editing (allow insert at caret).
  • History panel with previous expressions & results.
  • Memory functions (M+, M-, MR).
  • Extend factorial to Gamma for non-integer factorials.
  • Internationalization and accessibility improvements (larger buttons, TalkBack labels).

8. Conclusion

This Jetpack Compose scientific calculator balances practical UX with clean implementation: a compact expression evaluator, a modern Compose UI, clear DEG/RAD behavior, and polished result formatting. It is an excellent learning project that scales: add history, better editing, or replace the evaluator with a more powerful symbolic engine as your needs grow.

Special Message

Welcome to Coding