build(rechner): exact expression engine, keypad, scientific panel, history, memory; README

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-17 16:02:11 +02:00
co-authored by Claude Opus 5.5
parent 7ef64324fb
commit 5027fbcd85
40 changed files with 1899 additions and 30 deletions
+20
View File
@@ -0,0 +1,20 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- no INTERNET: a calculator has nothing to send anywhere -->
<uses-permission android:name="android.permission.VIBRATE" />
<application
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:roundIcon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:supportsRtl="true"
android:theme="@android:style/Theme.Material.NoActionBar">
<activity android:name=".MainActivity" android:exported="true" android:windowSoftInputMode="stateAlwaysHidden">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
<category android:name="android.intent.category.APP_CALCULATOR" />
</intent-filter>
</activity>
</application>
</manifest>
@@ -0,0 +1,26 @@
package app.rechner
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import app.rechner.data.Graph
import app.rechner.data.Prefs
import app.rechner.ui.CalculatorScreen
import app.rechner.ui.CalculatorViewModel
import app.rechner.ui.theme.CalcTheme
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
Graph.init(this)
val vm = CalculatorViewModel.get(this)
setContent {
val prefs by Graph.store.prefs.collectAsState(initial = Prefs())
CalcTheme(prefs.theme) { CalculatorScreen(vm, prefs) }
}
}
}
@@ -0,0 +1,79 @@
package app.rechner.data
import android.content.Context
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import androidx.room.ColumnInfo
import androidx.room.Dao
import androidx.room.Database
import androidx.room.Entity
import androidx.room.Index
import androidx.room.Insert
import androidx.room.PrimaryKey
import androidx.room.Query
import androidx.room.Room
import androidx.room.RoomDatabase
import app.rechner.engine.Angle
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
@Entity(tableName = "history", indices = [Index("created_at")])
data class HistoryEntry(
@PrimaryKey(autoGenerate = true) val id: Long = 0,
val expression: String,
val result: String,
@ColumnInfo(name = "created_at") val createdAt: Long = System.currentTimeMillis(),
)
@Dao
interface HistoryDao {
@Query("SELECT * FROM history ORDER BY created_at") fun all(): Flow<List<HistoryEntry>>
@Insert suspend fun insert(e: HistoryEntry)
@Query("DELETE FROM history") suspend fun clear()
/** keep the list bounded */
@Query("DELETE FROM history WHERE id NOT IN (SELECT id FROM history ORDER BY created_at DESC LIMIT 500)") suspend fun trim()
}
@Database(entities = [HistoryEntry::class], version = 1, exportSchema = true)
abstract class CalcDb : RoomDatabase() {
abstract fun history(): HistoryDao
}
enum class ThemeMode { SYSTEM, LIGHT, DARK }
data class Prefs(val angle: Angle = Angle.DEG, val memory: String? = null, val expression: String = "", val theme: ThemeMode = ThemeMode.SYSTEM)
private val Context.store by preferencesDataStore("calc")
class Store(private val context: Context) {
private val kAngle = stringPreferencesKey("angle")
private val kMemory = stringPreferencesKey("memory")
private val kExpr = stringPreferencesKey("expression")
private val kTheme = stringPreferencesKey("theme")
val prefs: Flow<Prefs> = context.store.data.map { p ->
Prefs(
angle = p[kAngle]?.let { runCatching { Angle.valueOf(it) }.getOrNull() } ?: Angle.DEG,
memory = p[kMemory],
expression = p[kExpr] ?: "",
theme = p[kTheme]?.let { runCatching { ThemeMode.valueOf(it) }.getOrNull() } ?: ThemeMode.SYSTEM,
)
}
suspend fun setAngle(a: Angle) = context.store.edit { it[kAngle] = a.name }
suspend fun setMemory(v: String?) = context.store.edit { if (v == null) it.remove(kMemory) else it[kMemory] = v }
suspend fun setExpression(v: String) = context.store.edit { it[kExpr] = v }
suspend fun setTheme(t: ThemeMode) = context.store.edit { it[kTheme] = t.name }
}
object Graph {
lateinit var history: HistoryDao private set
lateinit var store: Store private set
fun init(context: Context) {
if (::store.isInitialized) return
val app = context.applicationContext
history = Room.databaseBuilder(app, CalcDb::class.java, "calc.db").build().history()
store = Store(app)
}
}
@@ -0,0 +1,51 @@
package app.rechner.engine
/**
* Keypad editing on the expression text. Operators replace a trailing operator, the
* parenthesis key opens or closes depending on context, backspace removes whole tokens.
*/
object Editor {
private val binary = setOf('+', '−', '×', '÷', '^')
private val functionTokens = listOf("sin⁻¹(", "cos⁻¹(", "tan⁻¹(", "sin(", "cos(", "tan(", "ln(", "log(")
fun input(expr: String, key: String, decimal: Char): String {
val last = expr.lastOrNull()
return when {
key.length == 1 && key[0].isDigit() -> expr + key
key == decimal.toString() -> {
val number = expr.takeLastWhile { it.isDigit() || it == decimal || it == '.' }
when {
decimal in number -> expr
number.isEmpty() -> expr + "0" + decimal
else -> expr + decimal
}
}
key.length == 1 && key[0] in binary -> {
val op = key[0]
when {
expr.isEmpty() -> if (op == '−') "−" else expr
last != null && last in binary -> {
// "×−" is allowed for a negative factor, otherwise replace the operator
if (op == '−' && last != '−' && last != '+') expr + op else expr.dropLast(1) + op
}
last == '(' -> if (op == '−') expr + op else expr
else -> expr + op
}
}
key == "()" -> {
val open = expr.count { it == '(' } - expr.count { it == ')' }
val closeable = open > 0 && last != null && last != '(' && last !in binary
if (closeable) "$expr)" else "$expr("
}
key == "%" || key == "!" -> if (last != null && (last.isDigit() || last == ')' || last == 'π' || last == 'e' || last == '!' || last == '%')) expr + key else expr
key == "√" || key == "π" || key == "e" -> expr + key
key.endsWith("(") -> expr + key
else -> expr + key
}
}
fun backspace(expr: String): String {
functionTokens.firstOrNull { expr.endsWith(it) }?.let { return expr.dropLast(it.length) }
return expr.dropLast(1)
}
}
@@ -0,0 +1,297 @@
package app.rechner.engine
import java.math.BigDecimal
import java.math.BigInteger
import java.math.MathContext
import java.math.RoundingMode
import kotlin.math.PI
enum class Angle { DEG, RAD }
sealed interface Outcome {
data class Value(val value: BigDecimal) : Outcome
data class Error(val kind: ErrorKind) : Outcome
/** Nothing to show yet (empty or only an operator). */
data object Empty : Outcome
}
enum class ErrorKind { DIV_ZERO, DOMAIN, TOO_BIG, SYNTAX }
class CalcException(val kind: ErrorKind) : Exception(kind.name)
/**
* Parser and evaluator for the expressions the keypad produces.
* Display symbols: digits, decimal separator, + − × ÷ ^ ! % √ π e ( ), and the functions
* sin cos tan sin⁻¹ cos⁻¹ tan⁻¹ ln log. Unfinished input is tolerated: open parentheses are
* closed and a trailing operator is ignored, so the live preview always has something to show.
*/
class Engine(private val decimalSeparator: Char = ',') {
private val mc = MathContext(50, RoundingMode.HALF_EVEN)
fun evaluate(expression: String, angle: Angle = Angle.DEG, lenient: Boolean = true): Outcome {
var tokens = runCatching { tokenize(expression) }.getOrElse { return Outcome.Error(ErrorKind.SYNTAX) }
if (lenient) while (tokens.isNotEmpty() && tokens.last().let { it is Tok.Op || it is Tok.Func || it == Tok.Sqrt || it == Tok.Open }) tokens = tokens.dropLast(1)
if (tokens.isEmpty()) return Outcome.Empty
val open = tokens.count { it == Tok.Open } - tokens.count { it == Tok.Close }
if (open > 0) tokens = tokens + List(open) { Tok.Close }
return try {
val p = Parser(tokens, angle)
val v = p.parseExpression()
if (!p.atEnd()) throw CalcException(ErrorKind.SYNTAX)
if (v.abs() > LIMIT) throw CalcException(ErrorKind.TOO_BIG)
Outcome.Value(v)
} catch (e: CalcException) {
Outcome.Error(e.kind)
} catch (e: ArithmeticException) {
Outcome.Error(ErrorKind.DOMAIN)
}
}
// --- tokens --------------------------------------------------------------------------------
sealed interface Tok {
data class Num(val v: BigDecimal) : Tok
data class Op(val c: Char) : Tok
data class Func(val name: String) : Tok
data object Open : Tok
data object Close : Tok
data object Fact : Tok
data object Percent : Tok
data object Sqrt : Tok
data object Pi : Tok
data object E : Tok
}
private val functions = listOf("sin⁻¹", "cos⁻¹", "tan⁻¹", "sin", "cos", "tan", "ln", "log")
fun tokenize(s: String): List<Tok> {
val out = mutableListOf<Tok>()
var i = 0
while (i < s.length) {
val c = s[i]
when {
c.isWhitespace() || c == '.' && decimalSeparator == ',' -> i++ // grouping dots are cosmetic
c.isDigit() || c == decimalSeparator -> {
val start = i
while (i < s.length && (s[i].isDigit() || s[i] == decimalSeparator || (s[i] == '.' && decimalSeparator == ','))) i++
val raw = s.substring(start, i).filter { it != '.' || decimalSeparator == '.' }.replace(decimalSeparator, '.')
if (raw.count { it == '.' } > 1) throw CalcException(ErrorKind.SYNTAX)
// "E" exponent from pasted scientific results
var text = if (raw == ".") "0" else raw
if (i < s.length && s[i] == 'E' && i + 1 < s.length && (s[i + 1].isDigit() || s[i + 1] == '−' || s[i + 1] == '-')) {
val e0 = i + 1
i += 2
while (i < s.length && s[i].isDigit()) i++
text += "E" + s.substring(e0, i).replace('−', '-')
}
out += Tok.Num(BigDecimal(text))
}
c == '+' -> { out += Tok.Op('+'); i++ }
c == '−' || c == '-' -> { out += Tok.Op('−'); i++ }
c == '×' || c == '*' -> { out += Tok.Op('×'); i++ }
c == '÷' || c == '/' -> { out += Tok.Op('÷'); i++ }
c == '^' -> { out += Tok.Op('^'); i++ }
c == '(' -> { out += Tok.Open; i++ }
c == ')' -> { out += Tok.Close; i++ }
c == '!' -> { out += Tok.Fact; i++ }
c == '%' -> { out += Tok.Percent; i++ }
c == '√' -> { out += Tok.Sqrt; i++ }
c == 'π' -> { out += Tok.Pi; i++ }
c == 'e' -> { out += Tok.E; i++ }
else -> {
val f = functions.firstOrNull { s.startsWith(it, i) } ?: throw CalcException(ErrorKind.SYNTAX)
out += Tok.Func(f); i += f.length
if (i < s.length && s[i] == '(') { out += Tok.Open; i++ } else out += Tok.Open
}
}
}
return out
}
// --- parser --------------------------------------------------------------------------------
private inner class Parser(private val t: List<Tok>, private val angle: Angle) {
private var p = 0
fun atEnd() = p >= t.size
private fun peek() = t.getOrNull(p)
private fun next() = t[p++]
/** additive: a + b% means a + a·b/100 */
fun parseExpression(): BigDecimal {
var v = parseTerm()
while (true) {
val op = (peek() as? Tok.Op)?.c ?: break
if (op != '+' && op != '−') break
next()
val (rhs, wasPercent) = parseTermWithPercent()
val r = if (wasPercent) v.multiply(rhs, mc) else rhs
v = if (op == '+') v.add(r, mc) else v.subtract(r, mc)
}
return v
}
private fun parseTermWithPercent(): Pair<BigDecimal, Boolean> {
val start = p
val v = parseTerm()
// a term that is just "x%" counts as a percentage of the left side
val plainPercent = p - start >= 2 && t[p - 1] == Tok.Percent && t.subList(start, p - 1).none { it is Tok.Op || it == Tok.Percent }
return v to plainPercent
}
private fun startsPrimary(k: Tok?) = k is Tok.Num || k == Tok.Open || k is Tok.Func || k == Tok.Sqrt || k == Tok.Pi || k == Tok.E
private fun parseTerm(): BigDecimal {
var v = parseUnary()
while (true) {
val k = peek()
v = when {
k is Tok.Op && k.c == '×' -> { next(); v.multiply(parseUnary(), mc) }
k is Tok.Op && k.c == '÷' -> {
next()
val d = parseUnary()
if (d.signum() == 0) throw CalcException(ErrorKind.DIV_ZERO)
v.divide(d, mc)
}
startsPrimary(k) -> v.multiply(parseUnary(), mc) // implicit: 2π, 3(4), 2√9
else -> return v
}
}
}
private fun parseUnary(): BigDecimal {
val k = peek()
if (k is Tok.Op && k.c == '−') { next(); return parseUnary().negate() }
if (k is Tok.Op && k.c == '+') { next(); return parseUnary() }
return parsePower()
}
/** right associative, binds tighter than unary minus on the left: −2^2 = −4 */
private fun parsePower(): BigDecimal {
val base = parsePostfix()
val k = peek()
if (k is Tok.Op && k.c == '^') {
next()
return pow(base, parseUnary())
}
return base
}
private fun parsePostfix(): BigDecimal {
var v = parsePrimary()
while (true) {
v = when (peek()) {
Tok.Fact -> { next(); factorial(v) }
Tok.Percent -> { next(); v.divide(BigDecimal(100), mc) }
else -> return v
}
}
}
private fun parsePrimary(): BigDecimal {
return when (val k = next()) {
is Tok.Num -> k.v
Tok.Pi -> PI_BIG
Tok.E -> E_BIG
Tok.Open -> {
if (peek() == Tok.Close) throw CalcException(ErrorKind.SYNTAX)
val v = parseExpression()
if (peek() != Tok.Close) throw CalcException(ErrorKind.SYNTAX)
next(); v
}
Tok.Sqrt -> {
val x = parsePostfix()
if (x.signum() < 0) throw CalcException(ErrorKind.DOMAIN)
x.sqrt(mc)
}
is Tok.Func -> {
if (peek() != Tok.Open) throw CalcException(ErrorKind.SYNTAX)
next()
val x = parseExpression()
if (peek() != Tok.Close) throw CalcException(ErrorKind.SYNTAX)
next()
applyFunction(k.name, x)
}
else -> throw CalcException(ErrorKind.SYNTAX)
}
}
private fun toRad(x: Double) = if (angle == Angle.DEG) x * PI / 180 else x
private fun fromRad(x: Double) = if (angle == Angle.DEG) x * 180 / PI else x
private fun applyFunction(name: String, x: BigDecimal): BigDecimal {
val d = x.toDouble()
val r = when (name) {
"sin" -> exactTrig(x, 0) ?: kotlin.math.sin(toRad(d))
"cos" -> exactTrig(x, 1) ?: kotlin.math.cos(toRad(d))
"tan" -> {
if (angle == Angle.DEG && x.remainder(BigDecimal(180)).abs().compareTo(BigDecimal(90)) == 0) throw CalcException(ErrorKind.DOMAIN)
exactTrig(x, 2) ?: kotlin.math.tan(toRad(d))
}
"sin⁻¹" -> { if (d < -1 || d > 1) throw CalcException(ErrorKind.DOMAIN); fromRad(kotlin.math.asin(d)) }
"cos⁻¹" -> { if (d < -1 || d > 1) throw CalcException(ErrorKind.DOMAIN); fromRad(kotlin.math.acos(d)) }
"tan⁻¹" -> fromRad(kotlin.math.atan(d))
"ln" -> { if (d <= 0) throw CalcException(ErrorKind.DOMAIN); kotlin.math.ln(d) }
"log" -> { if (d <= 0) throw CalcException(ErrorKind.DOMAIN); exactLog10(x) ?: kotlin.math.log10(d) }
else -> throw CalcException(ErrorKind.SYNTAX)
}
if (r.isNaN() || r.isInfinite()) throw CalcException(ErrorKind.DOMAIN)
return clean(r)
}
/** sin/cos/tan of multiples of 30° and 45° (and π fractions in radians) are exact. */
private fun exactTrig(x: BigDecimal, which: Int): Double? {
val deg = if (angle == Angle.DEG) x else return null
val r = deg.remainder(BigDecimal(360)).let { if (it.signum() < 0) it.add(BigDecimal(360)) else it }
if (r.stripTrailingZeros().scale() > 0) return null
val a = r.toInt()
val table = mapOf(0 to Triple(0.0, 1.0, 0.0), 30 to Triple(.5, Double.NaN, Double.NaN), 90 to Triple(1.0, 0.0, Double.NaN),
150 to Triple(.5, Double.NaN, Double.NaN), 180 to Triple(0.0, -1.0, 0.0), 210 to Triple(-.5, Double.NaN, Double.NaN),
270 to Triple(-1.0, 0.0, Double.NaN), 330 to Triple(-.5, Double.NaN, Double.NaN),
45 to Triple(Double.NaN, Double.NaN, 1.0), 135 to Triple(Double.NaN, Double.NaN, -1.0), 225 to Triple(Double.NaN, Double.NaN, 1.0), 315 to Triple(Double.NaN, Double.NaN, -1.0),
60 to Triple(Double.NaN, .5, Double.NaN), 120 to Triple(Double.NaN, -.5, Double.NaN), 240 to Triple(Double.NaN, -.5, Double.NaN), 300 to Triple(Double.NaN, .5, Double.NaN))
val e = table[a] ?: return null
val v = when (which) { 0 -> e.first; 1 -> e.second; else -> e.third }
return v.takeIf { !it.isNaN() }
}
private fun exactLog10(x: BigDecimal): Double? {
val s = x.stripTrailingZeros()
if (s.unscaledValue() == BigInteger.ONE) return (-s.scale()).toDouble()
return null
}
private fun pow(base: BigDecimal, exp: BigDecimal): BigDecimal {
val e = exp.stripTrailingZeros()
if (e.scale() <= 0 && e.abs() <= BigDecimal(9999)) {
val n = e.toInt()
if (base.signum() == 0 && n < 0) throw CalcException(ErrorKind.DIV_ZERO)
val r = base.pow(kotlin.math.abs(n), mc)
return if (n < 0) BigDecimal.ONE.divide(r, mc) else r
}
if (base.signum() < 0) throw CalcException(ErrorKind.DOMAIN)
val r = Math.pow(base.toDouble(), exp.toDouble())
if (r.isNaN() || r.isInfinite()) throw CalcException(ErrorKind.TOO_BIG)
return clean(r)
}
private fun factorial(x: BigDecimal): BigDecimal {
val s = x.stripTrailingZeros()
if (s.signum() < 0 || s.scale() > 0) throw CalcException(ErrorKind.DOMAIN)
if (s > BigDecimal(1000)) throw CalcException(ErrorKind.TOO_BIG)
var r = BigInteger.ONE
for (i in 2..s.toInt()) r = r.multiply(BigInteger.valueOf(i.toLong()))
return BigDecimal(r)
}
/** Floating results get 15 significant digits, so cos(π) is −1 and not −0.9999999999999999. */
private fun clean(d: Double): BigDecimal = BigDecimal(d).round(MathContext(15, RoundingMode.HALF_EVEN)).let {
if (it.abs() < BigDecimal("1E-14")) BigDecimal.ZERO else it
}
}
companion object {
val PI_BIG = BigDecimal("3.14159265358979323846264338327950288419716939937510")
val E_BIG = BigDecimal("2.71828182845904523536028747135266249775724709369995")
private val LIMIT = BigDecimal("1E5000")
}
}
@@ -0,0 +1,36 @@
package app.rechner.engine
import java.math.BigDecimal
import java.math.MathContext
import java.math.RoundingMode
/** Turns exact results into display text: locale separators, no trailing zeros, scientific when huge or tiny. */
class NumberFormat(private val decimal: Char = ',', private val grouping: Char = '.') {
fun format(v: BigDecimal, maxDigits: Int = 12): String {
if (v.signum() == 0) return "0"
val abs = v.abs()
val sci = abs >= BigDecimal.TEN.pow(maxDigits) || abs < BigDecimal("1E-6")
if (sci) {
val r = v.round(MathContext(maxDigits - 3, RoundingMode.HALF_EVEN))
val exp = r.precision() - r.scale() - 1
val mant = r.movePointLeft(exp).stripTrailingZeros()
return plain(mant) + "E" + (if (exp < 0) "−" else "") + kotlin.math.abs(exp)
}
val intDigits = abs.setScale(0, RoundingMode.DOWN).toPlainString().length
val r = v.setScale((maxDigits - intDigits).coerceAtLeast(0), RoundingMode.HALF_EVEN).stripTrailingZeros()
return plain(r)
}
/** Every digit, for copy, scrolling and the history. */
fun full(v: BigDecimal): String = plain(v.round(MathContext(40, RoundingMode.HALF_EVEN)).stripTrailingZeros())
private fun plain(v: BigDecimal): String {
val s = v.toPlainString()
val neg = s.startsWith("-")
val body = s.removePrefix("-")
val int = body.substringBefore('.')
val frac = body.substringAfter('.', "")
val grouped = int.reversed().chunked(3).joinToString(grouping.toString()).reversed()
return (if (neg) "−" else "") + grouped + if (frac.isNotEmpty()) decimal + frac else ""
}
}
@@ -0,0 +1,379 @@
package app.rechner.ui
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import android.view.HapticFeedbackConstants
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.core.spring
import androidx.compose.animation.expandVertically
import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.gestures.draggable
import androidx.compose.foundation.gestures.rememberDraggableState
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsPressedAsState
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.Backspace
import androidx.compose.material.icons.rounded.ExpandLess
import androidx.compose.material.icons.rounded.ExpandMore
import androidx.compose.material.icons.rounded.History
import androidx.compose.material.icons.rounded.MoreVert
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.layout.positionInParent
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import app.rechner.R
import app.rechner.data.Graph
import app.rechner.data.HistoryEntry
import app.rechner.data.Prefs
import app.rechner.data.ThemeMode
import app.rechner.engine.Angle
import app.rechner.engine.ErrorKind
import app.rechner.ui.theme.LocalCalcColors
import app.rechner.ui.theme.WType
import kotlinx.coroutines.launch
import java.time.Instant
import java.time.LocalDate
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle
@Composable
fun CalculatorScreen(vm: CalculatorViewModel, prefs: Prefs) {
val state by vm.state.collectAsState()
val history by vm.history.collectAsState()
CalculatorContent(
state = state, history = history, prefs = prefs, decimal = vm.decimal,
onKey = vm::key, onBackspace = vm::backspace, onClear = vm::clear, onEquals = vm::equals,
onInsert = vm::insert, onAngle = vm::setAngle, onClearHistory = { vm.clearHistory() },
onMemoryStore = { vm.memoryStore(it) }, currentValue = vm::currentValue,
)
}
@Composable
fun CalculatorContent(
state: CalcState,
history: List<HistoryEntry>,
prefs: Prefs,
decimal: Char,
onKey: (String) -> Unit,
onBackspace: () -> Unit,
onClear: () -> Unit,
onEquals: () -> Unit,
onInsert: (String) -> Unit,
onAngle: (Angle) -> Unit,
onClearHistory: () -> Unit,
onMemoryStore: (String) -> Unit,
currentValue: () -> String,
initialHistoryOpen: Boolean = false,
initialScientific: Boolean = false,
) {
val c = LocalCalcColors.current
var scientific by rememberSaveable { mutableStateOf(initialScientific) }
var inverse by rememberSaveable { mutableStateOf(false) }
val scope = rememberCoroutineScope()
val view = LocalView.current
val tap: (() -> Unit) -> () -> Unit = { f -> { view.performHapticFeedback(HapticFeedbackConstants.KEYBOARD_TAP); f() } }
BoxWithConstraints(Modifier.fillMaxSize().background(c.bg)) {
val density = LocalDensity.current
val fullPx = with(density) { maxHeight.toPx() }
// the display is a sheet: collapsed above the keypad, pulled down it shows the history
val sheet = remember { Animatable(if (initialHistoryOpen) 1f else 0f) }
var keypadTop by remember { mutableStateOf(maxHeight * .4f) }
Column(Modifier.fillMaxSize()) {
Spacer(Modifier.weight(1f))
Column(Modifier.onGloballyPositioned { keypadTop = with(density) { it.positionInParent().y.toDp() } - 8.dp }) {
FunctionArea(scientific, inverse, prefs.angle, { scientific = !scientific }, { inverse = !inverse }, onAngle, onKey, tap)
Keypad(decimal, onKey, onBackspace, onClear, onEquals, tap, Modifier.navigationBarsPadding())
}
}
// the display reaches down to the function row; pulled down it covers the keypad
val collapsed by animateDpAsState(keypadTop, spring(.9f, 500f), label = "top")
val height = collapsed + (maxHeight - collapsed - 24.dp) * sheet.value
Column(
Modifier.fillMaxWidth().height(height).clip(RoundedCornerShape(bottomStart = 32.dp, bottomEnd = 32.dp)).background(c.display)
.draggable(
rememberDraggableState { d -> scope.launch { sheet.snapTo((sheet.value + d / (fullPx * .6f)).coerceIn(0f, 1f)) } },
Orientation.Vertical,
onDragStopped = { v -> scope.launch { sheet.animateTo(if (v > 300 || (v > -300 && sheet.value > .4f)) 1f else 0f, spring(.9f, 400f)) } },
)
.statusBarsPadding(),
) {
TopBar(
historyOpen = sheet.value > .5f,
onHistory = { scope.launch { sheet.animateTo(if (sheet.value > .5f) 0f else 1f, spring(.9f, 400f)) } },
onClearHistory = onClearHistory, theme = prefs.theme,
)
if (sheet.value > 0.01f) {
HistoryList(history, Modifier.weight(1f)) { onInsert(it); scope.launch { sheet.animateTo(0f, spring(.9f, 400f)) } }
Text(stringResource(R.string.current_expression), style = WType.label, color = c.muted, modifier = Modifier.padding(horizontal = 24.dp))
} else Spacer(Modifier.weight(1f))
Display(state, prefs, onInsert, onMemoryStore, currentValue)
// drag handle
Box(Modifier.fillMaxWidth().padding(vertical = 10.dp), contentAlignment = Alignment.Center) {
Box(Modifier.size(32.dp, 4.dp).clip(RoundedCornerShape(2.dp)).background(c.muted.copy(alpha = .6f)))
}
}
}
}
@Composable
private fun TopBar(historyOpen: Boolean, onHistory: () -> Unit, onClearHistory: () -> Unit, theme: ThemeMode) {
val c = LocalCalcColors.current
var menu by remember { mutableStateOf(false) }
val scope = rememberCoroutineScope()
Row(Modifier.fillMaxWidth().padding(horizontal = 8.dp), verticalAlignment = Alignment.CenterVertically) {
IconButton(onClick = onHistory) {
Box(Modifier.size(36.dp).clip(CircleShape).background(if (historyOpen) c.op else Color.Transparent), contentAlignment = Alignment.Center) {
Icon(Icons.Rounded.History, stringResource(R.string.history), tint = if (historyOpen) c.onOp else c.muted)
}
}
Spacer(Modifier.weight(1f))
IconButton(onClick = { menu = true }) { Icon(Icons.Rounded.MoreVert, stringResource(R.string.more), tint = c.muted) }
DropdownMenu(menu, { menu = false }) {
DropdownMenuItem(text = { Text(stringResource(R.string.clear_history)) }, onClick = { menu = false; onClearHistory() })
val next = when (theme) { ThemeMode.SYSTEM -> ThemeMode.DARK; ThemeMode.DARK -> ThemeMode.LIGHT; ThemeMode.LIGHT -> ThemeMode.SYSTEM }
val label = mapOf(ThemeMode.SYSTEM to R.string.theme_system, ThemeMode.DARK to R.string.theme_dark, ThemeMode.LIGHT to R.string.theme_light)
DropdownMenuItem(
text = { Text(stringResource(R.string.theme) + ": " + stringResource(label.getValue(theme))) },
onClick = { scope.launch { Graph.store.setTheme(next) } },
)
}
}
}
@Composable
private fun HistoryList(history: List<HistoryEntry>, modifier: Modifier, onPick: (String) -> Unit) {
val c = LocalCalcColors.current
val state = rememberLazyListState()
LaunchedEffect(history.size) { if (history.isNotEmpty()) state.scrollToItem(history.size + countDays(history)) }
if (history.isEmpty()) {
Box(modifier.fillMaxWidth(), contentAlignment = Alignment.Center) { Text(stringResource(R.string.no_history), style = WType.body, color = c.muted) }
return
}
val res = LocalContext.current.resources
LazyColumn(modifier.fillMaxWidth(), state = state, verticalArrangement = Arrangement.Bottom) {
var lastDay: LocalDate? = null
history.forEach { h ->
val day = Instant.ofEpochMilli(h.createdAt).atZone(ZoneId.systemDefault()).toLocalDate()
if (day != lastDay) {
val d = day
item(key = "d$d") {
val label = when (d) {
LocalDate.now() -> res.getString(R.string.today)
LocalDate.now().minusDays(1) -> res.getString(R.string.yesterday)
else -> d.format(DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM))
}
Text(label, style = WType.label, color = c.muted, modifier = Modifier.padding(start = 24.dp, top = 16.dp, bottom = 4.dp))
}
lastDay = day
}
item(key = h.id) {
Column(
Modifier.fillMaxWidth().clickable { onPick(h.result) }.padding(horizontal = 24.dp, vertical = 8.dp),
horizontalAlignment = Alignment.End,
) {
Text(h.expression, style = WType.historyExpr, color = c.muted, maxLines = 1)
Text(h.result, style = WType.historyResult, color = c.text, maxLines = 1)
}
}
}
}
}
private fun countDays(h: List<HistoryEntry>) = h.map { Instant.ofEpochMilli(it.createdAt).atZone(ZoneId.systemDefault()).toLocalDate() }.distinct().size
@Composable
private fun Display(state: CalcState, prefs: Prefs, onInsert: (String) -> Unit, onMemoryStore: (String) -> Unit, currentValue: () -> String) {
val c = LocalCalcColors.current
val context = LocalContext.current
var menu by remember { mutableStateOf(false) }
val clipboard = context.getSystemService(ClipboardManager::class.java)
val scroll = rememberScrollState()
LaunchedEffect(state.expression) { scroll.scrollTo(scroll.maxValue) }
Column(Modifier.fillMaxWidth().padding(horizontal = 24.dp), horizontalAlignment = Alignment.End) {
Box {
val text = state.expression.ifEmpty { " " }
// shrink with length, then scroll
val size = when { text.length <= 7 -> 72.sp; text.length <= 10 -> 56.sp; text.length <= 14 -> 44.sp; else -> 36.sp }
Row(Modifier.horizontalScroll(scroll)) {
Text(
text, style = WType.expr.copy(fontSize = size, lineHeight = size * 1.1f, fontFeatureSettings = "tnum"), color = c.text, maxLines = 1,
modifier = Modifier.combinedClickable(onClick = {}, onLongClick = { menu = true }).semantics { contentDescription = speak(state.expression) },
)
}
DropdownMenu(menu, { menu = false }, shape = RoundedCornerShape(24.dp)) {
DropdownMenuItem(text = { Text(stringResource(R.string.copy)) }, onClick = {
menu = false; clipboard.setPrimaryClip(ClipData.newPlainText("Ergebnis", currentValue()))
})
DropdownMenuItem(text = { Text(stringResource(R.string.paste)) }, onClick = {
menu = false; clipboard.primaryClip?.getItemAt(0)?.coerceToText(context)?.toString()?.let(onInsert)
})
DropdownMenuItem(text = { Text("MS") }, onClick = { menu = false; onMemoryStore(currentValue()) })
if (prefs.memory != null) DropdownMenuItem(text = { Text("MR " + prefs.memory) }, onClick = { menu = false; onInsert(prefs.memory) })
}
}
val second = when {
state.error != null -> errorText(state.error)
state.showingResult -> ""
else -> state.preview
}
Text(
second.ifEmpty { " " }, style = if (state.error != null) WType.fn else WType.result.copy(fontFeatureSettings = "tnum"),
color = if (state.error != null) c.error else c.result, maxLines = 1, textAlign = TextAlign.End,
)
}
}
@Composable
private fun errorText(e: ErrorKind) = stringResource(
when (e) { ErrorKind.DIV_ZERO -> R.string.err_div_zero; ErrorKind.DOMAIN -> R.string.err_domain; ErrorKind.TOO_BIG -> R.string.err_too_big; ErrorKind.SYNTAX -> R.string.err_syntax },
)
private fun speak(e: String) = e.replace("×", " mal ").replace("÷", " geteilt durch ").replace("−", " minus ").replace("+", " plus ").replace("√", " Wurzel ").replace("π", " Pi ")
@Composable
private fun FunctionArea(
expanded: Boolean, inverse: Boolean, angle: Angle,
onToggle: () -> Unit, onInverse: () -> Unit, onAngle: (Angle) -> Unit, onKey: (String) -> Unit, tap: (() -> Unit) -> () -> Unit,
) {
val c = LocalCalcColors.current
Column(Modifier.fillMaxWidth().padding(horizontal = 12.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
listOf("√" to "√", "π" to "π", "^" to "^", "!" to "!").forEach { (label, key) ->
FnKey(label, expanded, tap { onKey(key) })
}
Box(Modifier.padding(start = 6.dp).size(40.dp).clip(CircleShape).background(c.fn).clickable(onClick = onToggle), contentAlignment = Alignment.Center) {
Icon(if (expanded) Icons.Rounded.ExpandLess else Icons.Rounded.ExpandMore, stringResource(R.string.more_functions), tint = c.onFn)
}
}
AnimatedVisibility(expanded, enter = expandVertically(spring(.9f, 500f)), exit = shrinkVertically(spring(.9f, 500f))) {
Column {
Row {
FnKey(if (angle == Angle.DEG) "Deg" else "Rad", true, tap { onAngle(if (angle == Angle.DEG) Angle.RAD else Angle.DEG) })
if (inverse) { FnKey("sin⁻¹", true, tap { onKey("sin⁻¹(") }); FnKey("cos⁻¹", true, tap { onKey("cos⁻¹(") }); FnKey("tan⁻¹", true, tap { onKey("tan⁻¹(") }) }
else { FnKey("sin", true, tap { onKey("sin(") }); FnKey("cos", true, tap { onKey("cos(") }); FnKey("tan", true, tap { onKey("tan(") }) }
Spacer(Modifier.width(46.dp))
}
Row {
FnKey("Inv", true, onInverse, selected = inverse)
if (inverse) { FnKey("eˣ", true, tap { onKey("e^") }); FnKey("10ˣ", true, tap { onKey("10^") }); FnKey("x²", true, tap { onKey("^2") }) }
else { FnKey("e", true, tap { onKey("e") }); FnKey("ln", true, tap { onKey("ln(") }); FnKey("log", true, tap { onKey("log(") }) }
Spacer(Modifier.width(46.dp))
}
}
}
}
}
@Composable
private fun RowScope.FnKey(label: String, tonal: Boolean, onClick: () -> Unit, selected: Boolean = false) {
val c = LocalCalcColors.current
Box(
Modifier.weight(1f).padding(4.dp).height(44.dp).clip(RoundedCornerShape(22.dp))
.background(if (selected) c.op else if (tonal) c.fn else Color.Transparent).clickable(onClick = onClick),
contentAlignment = Alignment.Center,
) { Text(label, style = WType.fn, color = if (selected) c.onOp else c.onFn) }
}
@Composable
private fun Keypad(
decimal: Char, onKey: (String) -> Unit, onBackspace: () -> Unit, onClear: () -> Unit, onEquals: () -> Unit,
tap: (() -> Unit) -> () -> Unit, modifier: Modifier,
) {
val c = LocalCalcColors.current
data class K(val label: String, val bg: Color, val fg: Color, val action: () -> Unit, val icon: ImageVector? = null, val long: (() -> Unit)? = null, val desc: String = label)
val d = K("", c.digit, c.onDigit, {})
fun digit(s: String) = d.copy(label = s, action = tap { onKey(s) })
fun op(s: String, desc: String) = K(s, c.op, c.onOp, tap { onKey(s) }, desc = desc)
val rows = listOf(
listOf(K("AC", c.ac, c.onAc, tap(onClear), desc = "Alles löschen"), op("()", "Klammer"), op("%", "Prozent"), op("÷", "geteilt durch")),
listOf(digit("7"), digit("8"), digit("9"), op("×", "mal")),
listOf(digit("4"), digit("5"), digit("6"), op("−", "minus")),
listOf(digit("1"), digit("2"), digit("3"), op("+", "plus")),
listOf(digit("0"), digit(decimal.toString()), K("", c.digit, c.onDigit, tap(onBackspace), Icons.AutoMirrored.Rounded.Backspace, long = onClear, desc = "Löschen"),
K("=", c.eq, c.onEq, tap(onEquals), desc = "gleich")),
)
Column(modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
rows.forEach { row ->
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
row.forEach { k -> Key(k.label, k.bg, k.fg, k.icon, k.desc, k.action, k.long, Modifier.weight(1f)) }
}
}
}
}
/** Circle at rest, squircle while pressed: the expressive press. */
@Composable
private fun Key(label: String, bg: Color, fg: Color, icon: ImageVector?, desc: String, onClick: () -> Unit, onLong: (() -> Unit)?, modifier: Modifier) {
val source = remember { MutableInteractionSource() }
val pressed by source.collectIsPressedAsState()
val radius by animateDpAsState(if (pressed) 22.dp else 100.dp, spring(.6f, 800f), label = "r")
Box(
modifier.aspectRatio(1.18f).clip(RoundedCornerShape(radius)).background(bg)
.combinedClickable(interactionSource = source, indication = null, onClick = onClick, onLongClick = onLong)
.semantics { contentDescription = desc },
contentAlignment = Alignment.Center,
) {
if (icon != null) Icon(icon, null, tint = fg, modifier = Modifier.size(30.dp))
else Text(label, style = WType.key, color = fg)
}
}
@@ -0,0 +1,115 @@
package app.rechner.ui
import androidx.activity.ComponentActivity
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import app.rechner.data.Graph
import app.rechner.data.HistoryEntry
import app.rechner.engine.Angle
import app.rechner.engine.Editor
import app.rechner.engine.Engine
import app.rechner.engine.ErrorKind
import app.rechner.engine.NumberFormat
import app.rechner.engine.Outcome
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import java.text.DecimalFormatSymbols
data class CalcState(
val expression: String = "",
/** live preview, empty when there is nothing new to show */
val preview: String = "",
val error: ErrorKind? = null,
/** after "=": the expression is a result and the next digit starts over */
val showingResult: Boolean = false,
val previous: String = "",
)
class CalculatorViewModel : ViewModel() {
val decimal: Char = DecimalFormatSymbols.getInstance().decimalSeparator
private val grouping: Char = DecimalFormatSymbols.getInstance().groupingSeparator.let { if (it == decimal) ' ' else it }
private val engine = Engine(decimal)
val format = NumberFormat(decimal, grouping)
private var angle = Angle.DEG
private val _state = MutableStateFlow(CalcState())
val state: StateFlow<CalcState> = _state
val history: StateFlow<List<HistoryEntry>> = Graph.history.all().stateIn(viewModelScope, SharingStarted.Eagerly, emptyList())
init {
viewModelScope.launch {
val p = Graph.store.prefs.first()
angle = p.angle
update(p.expression)
}
}
fun setAngle(a: Angle) {
angle = a
viewModelScope.launch { Graph.store.setAngle(a) }
update(_state.value.expression, keepResult = _state.value.showingResult)
}
fun key(k: String) {
val s = _state.value
val base = when {
// a digit or function after "=" starts a new calculation; an operator continues with the result
s.showingResult && (k.first().isDigit() || k == decimal.toString() || k.endsWith("(") || k in setOf("√", "π", "e", "()")) -> ""
s.error != null && s.showingResult -> ""
else -> s.expression
}
update(Editor.input(base, k, decimal))
}
fun backspace() = update(if (_state.value.showingResult) "" else Editor.backspace(_state.value.expression))
fun clear() = update("")
fun insert(text: String) = update(_state.value.let { if (it.showingResult) "" else it.expression } + sanitize(text))
fun equals() {
val s = _state.value
if (s.expression.isEmpty() || s.showingResult) return
when (val r = engine.evaluate(s.expression, angle, lenient = true)) {
is Outcome.Value -> {
val result = format.format(r.value, 15)
viewModelScope.launch { Graph.history.insert(HistoryEntry(expression = s.expression, result = result)); Graph.history.trim() }
_state.value = CalcState(expression = result, previous = s.expression, showingResult = true)
persist(result)
}
is Outcome.Error -> _state.value = s.copy(error = r.kind, preview = "")
Outcome.Empty -> Unit
}
}
fun clearHistory() = viewModelScope.launch { Graph.history.clear() }
fun memoryStore(value: String) = viewModelScope.launch { Graph.store.setMemory(value) }
/** The value currently shown: result after "=", else the live preview, else the expression. */
fun currentValue(): String = _state.value.let { if (it.showingResult) it.expression else it.preview.ifEmpty { it.expression } }
private fun update(expr: String, keepResult: Boolean = false) {
val r = engine.evaluate(expr, angle, lenient = true)
val preview = when (r) {
is Outcome.Value -> format.format(r.value).takeIf { it != expr && hasOperation(expr) } ?: ""
else -> ""
}
_state.value = CalcState(expression = expr, preview = preview, showingResult = keepResult)
persist(expr)
}
private fun hasOperation(e: String) = e.any { it in "+−×÷^!%√πe(" } || e.contains("sin") || e.contains("cos") || e.contains("tan") || e.contains("ln") || e.contains("log")
private fun persist(expr: String) = viewModelScope.launch { Graph.store.setExpression(expr) }
/** Paste keeps only what the keypad could type. */
private fun sanitize(t: String) = t.replace('*', '×').replace('/', '÷').replace('-', '−')
.filter { it.isDigit() || it == decimal || it == grouping || it in "+−×÷^!%√πe()E" || it.isLetter() }
companion object {
fun get(activity: ComponentActivity) = ViewModelProvider(activity)[CalculatorViewModel::class.java]
}
}
@@ -0,0 +1,61 @@
package app.rechner.ui.theme
import android.os.Build
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.ColorScheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import app.rechner.data.ThemeMode
/** Key and display roles; from the wallpaper on Android 12+, from tokens otherwise. */
@Immutable
data class CalcColors(
val bg: Color, val display: Color, val text: Color, val muted: Color, val result: Color, val error: Color,
val digit: Color, val onDigit: Color, val op: Color, val onOp: Color, val ac: Color, val onAc: Color,
val eq: Color, val onEq: Color, val fn: Color, val onFn: Color,
)
private val LightCalc = CalcColors(
WColor.bg, WColor.display, WColor.text, WColor.textMuted, WColor.result, WColor.error,
WColor.keyDigit, WColor.onKeyDigit, WColor.keyOp, WColor.onKeyOp, WColor.keyAc, WColor.onKeyAc,
WColor.keyEq, WColor.onKeyEq, WColor.keyFn, WColor.onKeyFn,
)
private val DarkCalc = CalcColors(
WColor.darkBg, WColor.darkDisplay, WColor.darkText, WColor.darkTextMuted, WColor.darkResult, WColor.darkError,
WColor.darkKeyDigit, WColor.darkOnKeyDigit, WColor.darkKeyOp, WColor.darkOnKeyOp, WColor.darkKeyAc, WColor.darkOnKeyAc,
WColor.darkKeyEq, WColor.darkOnKeyEq, WColor.darkKeyFn, WColor.darkOnKeyFn,
)
private fun fromScheme(s: ColorScheme, dark: Boolean) = CalcColors(
bg = s.surface, display = s.surfaceContainerHigh, text = s.onSurface, muted = s.onSurfaceVariant, result = s.onSurfaceVariant, error = s.error,
digit = if (dark) s.surfaceContainer else s.surfaceContainerLow, onDigit = s.onSurface,
op = s.secondaryContainer, onOp = s.onSecondaryContainer,
ac = s.tertiaryContainer, onAc = s.onTertiaryContainer,
eq = if (dark) s.primary else s.primaryContainer, onEq = if (dark) s.onPrimary else s.onPrimaryContainer,
fn = s.surfaceContainerHighest, onFn = s.onSurface,
)
val LocalCalcColors = staticCompositionLocalOf { LightCalc }
@Composable
fun CalcTheme(mode: ThemeMode = ThemeMode.SYSTEM, dynamic: Boolean = true, content: @Composable () -> Unit) {
val dark = when (mode) { ThemeMode.SYSTEM -> isSystemInDarkTheme(); ThemeMode.LIGHT -> false; ThemeMode.DARK -> true }
val context = LocalContext.current
val useDynamic = dynamic && Build.VERSION.SDK_INT >= 31
val scheme = when {
useDynamic -> if (dark) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
dark -> darkColorScheme()
else -> lightColorScheme()
}
val calc = if (useDynamic) fromScheme(scheme, dark) else if (dark) DarkCalc else LightCalc
CompositionLocalProvider(LocalCalcColors provides calc) { MaterialTheme(colorScheme = scheme, content = content) }
}
@@ -0,0 +1,75 @@
// Generated from tokens.json by gen_theme.py. Do not edit by hand.
package app.rechner.ui.theme
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
object WColor {
val bg = Color(0xFFF9F9FF)
val display = Color(0xFFE1E5EE)
val sheet = Color(0xFFE8EBF2)
val text = Color(0xFF191C20)
val textMuted = Color(0xFF43474E)
val result = Color(0xFF5A5F68)
val keyDigit = Color(0xFFEEF0F5)
val onKeyDigit = Color(0xFF191C20)
val keyOp = Color(0xFFD6E3F5)
val onKeyOp = Color(0xFF0E1D2E)
val keyAc = Color(0xFFEAD9FF)
val onKeyAc = Color(0xFF25113D)
val keyEq = Color(0xFFCDE3FB)
val onKeyEq = Color(0xFF06233D)
val keyFn = Color(0xFFDDE3EC)
val onKeyFn = Color(0xFF191C20)
val error = Color(0xFFBA1A1A)
val darkBg = Color(0xFF0B1414)
val darkDisplay = Color(0xFF283535)
val darkSheet = Color(0xFF1E2A2A)
val darkText = Color(0xFFE0E6E6)
val darkTextMuted = Color(0xFFBEC8C8)
val darkResult = Color(0xFF9FB0B0)
val darkKeyDigit = Color(0xFF172121)
val darkOnKeyDigit = Color(0xFFDDE4E4)
val darkKeyOp = Color(0xFFA3D1D3)
val darkOnKeyOp = Color(0xFF002F31)
val darkKeyAc = Color(0xFF8FD5E8)
val darkOnKeyAc = Color(0xFF00303B)
val darkKeyEq = Color(0xFF00E0CC)
val darkOnKeyEq = Color(0xFF00332E)
val darkKeyFn = Color(0xFF253232)
val darkOnKeyFn = Color(0xFFDDE4E4)
val darkError = Color(0xFFFFB4AB)
}
object WType {
val label = TextStyle(fontSize = 14.sp, lineHeight = 20.sp, fontWeight = FontWeight.W500)
val body = TextStyle(fontSize = 16.sp, lineHeight = 24.sp, fontWeight = FontWeight.W400)
val fn = TextStyle(fontSize = 22.sp, lineHeight = 28.sp, fontWeight = FontWeight.W400)
val key = TextStyle(fontSize = 34.sp, lineHeight = 40.sp, fontWeight = FontWeight.W400)
val result = TextStyle(fontSize = 36.sp, lineHeight = 44.sp, fontWeight = FontWeight.W400)
val expr = TextStyle(fontSize = 72.sp, lineHeight = 80.sp, fontWeight = FontWeight.W400)
val exprMin = TextStyle(fontSize = 36.sp, lineHeight = 44.sp, fontWeight = FontWeight.W400)
val historyExpr = TextStyle(fontSize = 18.sp, lineHeight = 24.sp, fontWeight = FontWeight.W400)
val historyResult = TextStyle(fontSize = 28.sp, lineHeight = 34.sp, fontWeight = FontWeight.W400)
}
object WSpace {
val s0 = 0.dp
val s4 = 4.dp
val s8 = 8.dp
val s12 = 12.dp
val s16 = 16.dp
val s24 = 24.dp
val s32 = 32.dp
}
object WRadius {
val display = 32.dp
val key = 999.dp
val keyPressed = 24.dp
val menu = 24.dp
val pill = 999.dp
}
@@ -0,0 +1 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="108dp" android:height="108dp" android:viewportWidth="108" android:viewportHeight="108"><path android:fillColor="#FF1E3A5F" android:pathData="M0,0h108v108h-108z"/></vector>
@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="108dp" android:height="108dp" android:viewportWidth="108" android:viewportHeight="108">
<path android:fillColor="#FFD6E3FF" android:pathData="M37,30h8a7,7 0 0 1 7,7v8a7,7 0 0 1 -7,7h-8a7,7 0 0 1 -7,-7v-8a7,7 0 0 1 7,-7z"/>
<path android:fillColor="#FFD6E3FF" android:pathData="M63,30h8a7,7 0 0 1 7,7v8a7,7 0 0 1 -7,7h-8a7,7 0 0 1 -7,-7v-8a7,7 0 0 1 7,-7z"/>
<path android:fillColor="#FFD6E3FF" android:pathData="M37,56h8a7,7 0 0 1 7,7v8a7,7 0 0 1 -7,7h-8a7,7 0 0 1 -7,-7v-8a7,7 0 0 1 7,-7z"/>
<path android:fillColor="#FFFFB86B" android:pathData="M63,56h8a7,7 0 0 1 7,7v8a7,7 0 0 1 -7,7h-8a7,7 0 0 1 -7,-7v-8a7,7 0 0 1 7,-7z"/>
<path android:strokeColor="#FF1E3A5F" android:strokeWidth="2.6" android:strokeLineCap="round" android:pathData="M36,41h10M41,36v10"/>
<path android:strokeColor="#FF1E3A5F" android:strokeWidth="2.6" android:strokeLineCap="round" android:pathData="M62,41h10"/>
<path android:strokeColor="#FF1E3A5F" android:strokeWidth="2.6" android:strokeLineCap="round" android:pathData="M37.5,63.5l7,7M44.5,63.5l-7,7"/>
<path android:strokeColor="#FF5A2E00" android:strokeWidth="2.6" android:strokeLineCap="round" android:pathData="M62,64h10M62,70h10"/>
</vector>
@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="108dp" android:height="108dp" android:viewportWidth="108" android:viewportHeight="108">
<path android:fillColor="#FF000000" android:pathData="M37,30h8a7,7 0 0 1 7,7v8a7,7 0 0 1 -7,7h-8a7,7 0 0 1 -7,-7v-8a7,7 0 0 1 7,-7z"/>
<path android:fillColor="#FF000000" android:pathData="M63,30h8a7,7 0 0 1 7,7v8a7,7 0 0 1 -7,7h-8a7,7 0 0 1 -7,-7v-8a7,7 0 0 1 7,-7z"/>
<path android:fillColor="#FF000000" android:pathData="M37,56h8a7,7 0 0 1 7,7v8a7,7 0 0 1 -7,7h-8a7,7 0 0 1 -7,-7v-8a7,7 0 0 1 7,-7z"/>
<path android:fillColor="#FF000000" android:pathData="M63,56h8a7,7 0 0 1 7,7v8a7,7 0 0 1 -7,7h-8a7,7 0 0 1 -7,-7v-8a7,7 0 0 1 7,-7z"/>
<path android:strokeColor="#FFFFFFFF" android:strokeWidth="2.6" android:strokeLineCap="round" android:pathData="M36,41h10M41,36v10"/>
<path android:strokeColor="#FFFFFFFF" android:strokeWidth="2.6" android:strokeLineCap="round" android:pathData="M62,41h10"/>
<path android:strokeColor="#FFFFFFFF" android:strokeWidth="2.6" android:strokeLineCap="round" android:pathData="M37.5,63.5l7,7M44.5,63.5l-7,7"/>
<path android:strokeColor="#FFFFFFFF" android:strokeWidth="2.6" android:strokeLineCap="round" android:pathData="M62,64h10M62,70h10"/>
</vector>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
<monochrome android:drawable="@drawable/ic_launcher_monochrome"/>
</adaptive-icon>
@@ -0,0 +1,21 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="history">Verlauf</string>
<string name="more">Weitere Optionen</string>
<string name="more_functions">Weitere Funktionen</string>
<string name="clear_history">Verlauf löschen</string>
<string name="no_history">Noch keine Rechnungen</string>
<string name="current_expression">Aktuelle Eingabe</string>
<string name="today">Heute</string>
<string name="yesterday">Gestern</string>
<string name="copy">Kopieren</string>
<string name="paste">Einfügen</string>
<string name="theme">Design</string>
<string name="theme_system">System</string>
<string name="theme_light">Hell</string>
<string name="theme_dark">Dunkel</string>
<string name="err_div_zero">Division durch null geht nicht</string>
<string name="err_domain">Nicht definiert</string>
<string name="err_too_big">Zahl zu groß</string>
<string name="err_syntax">Ungültige Eingabe</string>
</resources>