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:
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.