Best Tutorial for AndroidDevelopers

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  - Responsive Blogger Template
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()
                
            }
        }
    }
}

SkillDedication

Build a Tic-Tac-Toe GUI in Python — Explore Expert-Written Tutorials, Projects, and Tools to Master in Python —

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.

πŸ“Œ FAQ – Material Design 3 Expressive Components in Jetpack Compose

1. ❓ What are Material Design "Expressive" components?

Answer:

They are enhanced versions of standard Material 3 components that include:
Richer motion/animation feedback
Support for dynamic color (Material You)
Icon integration inside controls (like switches & buttons)
New patterns like Split Button, Button Groups, and FAB Menu

2. ❓ Do I need a special library for Expressive components in Jetpack Compose?

Answer:

No 🚫 — Expressive components are part of Jetpack Compose Material3.
Some are stable (Buttons, FABs, Navigation, Sliders), while others are still experimental (Segmented Buttons, Split Button, Switch with icons).

3. ❓ Will my app automatically support dark mode & dynamic theming?

Answer:

Yes ✅ — expressive components follow Material You dynamic color automatically if you use MaterialTheme.colorScheme.
No extra work is required unless you override colors.

4. ❓ How do I keep UI state (like calculator result) when theme changes?

Answer:

Use rememberSaveable or a ViewModel.
Example:
var result by rememberSaveable { mutableStateOf("") }

This prevents reset when theme or orientation changes.

5. ❓ What is the difference between Extended FAB and FAB Menu?

Answer:

Extended FAB: A single FAB + Label for one action.
FAB Menu: A main FAB that expands into multiple actions (new in expressive patterns).

6. ❓ What is a Split Button?

Answer:

A button with two sections:
Main action (e.g., Save)
Secondary dropdown (e.g., Save As, Export)
In Compose, you can build it using Row + DropdownMenu.

7. ❓ Are Segmented Buttons the same as TabRow?

Answer:

Not exactly.
Segmented Button Group = small group of related actions (Day/Week/Month).
TabRow = navigation across different screens.

8. ❓ What is the benefit of Expressive Switch vs Regular Switch?

Answer:

Expressive Switch can show icons inside the thumb (✔️, ✖️, πŸ”’, etc.), giving clearer visual feedback.

9. ❓ Which expressive components are best for calculators or utility apps?

Answer:

Button Groups → mode selection (Deg/Rad, Basic/Scientific).
Switch → theme toggle.
FAB / FAB Menu → quick actions like “History” or “Clear”.
Sliders → scientific constants or precision adjustments.

10. ❓ Are all 15 components production-ready?

Answer:

Most are stable (App bars, Buttons, FABs, Nav, Sliders, Progress).
Some (Segmented Buttons, Split Button, Expressive Switch) are still experimental in Jetpack Compose.
πŸ‘‰ Use @OptIn(ExperimentalMaterial3Api::class) when needed.
Sandeep Kumar - Android Developer

About the Author

Sandeep Kumar is an Android developer and educator who writes beginner-friendly Jetpack Compose tutorials on CodingBihar.com. His focus is on clean UI, Material Design 3, and real-world Android apps.

SkillDedication

— Python High Level Programming Language- Expert-Written Tutorials, Projects, and Tools—

Coding Bihar

Welcome To Coding BiharπŸ‘¨‍🏫