package com.vgmlr.grain
import android.app.Application
import android.content.ClipData
import android.content.ClipboardManager
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import androidx.compose.runtime.mutableFloatStateOf
private const val CLIP_LABEL = "grain"
// process scope
object GrainMemory {
var value by mutableStateOf<Double?>(null)
}
enum class Pad(val label: String, val num: Boolean) {
CLR("C", false), MEM("M", false), PAR("( )", false), PCT("%", false),
N7("7", true), N8("8", true), N9("9", true), DIV("/", false),
N4("4", true), N5("5", true), N6("6", true), MUL("×", false),
N1("1", true), N2("2", true), N3("3", true), SUB("-", false),
DOT(".", true), N0("0", true), ADD("+", false), EQ("=", false)
}
class GrainViewModel(app: Application) : AndroidViewModel(app) {
val prefs = GrainPrefs(app)
val bias: StateFlow<Bias> =
prefs.bias.stateIn(viewModelScope, SharingStarted.Eagerly, Bias.LEFT)
val edgePad: StateFlow<Int> =
prefs.edgePad.stateIn(viewModelScope, SharingStarted.Eagerly, PAD_DEFAULT)
val opsSide: StateFlow<Side> =
prefs.opsSide.stateIn(viewModelScope, SharingStarted.Eagerly, Side.RIGHT)
val decimals: StateFlow<Int> =
prefs.decimals.stateIn(viewModelScope, SharingStarted.Eagerly, DEC_DEFAULT)
private val places: Int get() = decimals.value
val digits: StateFlow<Digits> =
prefs.digits.stateIn(viewModelScope, SharingStarted.Eagerly, Digits.MONO)
val keySize: StateFlow<Int> =
prefs.keySize.stateIn(viewModelScope, SharingStarted.Eagerly, KEY_AUTO)
var keypadW by mutableFloatStateOf(0f) // dp
val historyMax: StateFlow<Int> =
prefs.historyMax.stateIn(viewModelScope, SharingStarted.Eagerly, HISTORY_DEFAULT)
val history: StateFlow<List<String>> =
prefs.history.stateIn(viewModelScope, SharingStarted.Eagerly, emptyList())
private val clip = app.getSystemService(ClipboardManager::class.java)
// calc
var expr by mutableStateOf("")
private set
var solved by mutableStateOf(false)
private set
var error by mutableStateOf(false)
private set
val display: String get() = if (error) ERR else expr
val memText: String? get() = GrainMemory.value?.let { GrainCalc.fmt(it, places) }
// convert
var category by mutableStateOf<Category?>(null)
private set
var unitA by mutableIntStateOf(0)
private set
var unitB by mutableIntStateOf(0)
private set
var focusA by mutableStateOf(true)
private set
private var input by mutableStateOf("")
private var pinned by mutableStateOf<String?>(null) // prior value
private var fresh = false
val output: String
get() {
val c = category ?: return ""
pinned?.let { return it }
val v = GrainCalc.parse(input) ?: return ""
val (f, t) = if (focusA) unitA to unitB else unitB to unitA
return GrainCalc.fmt(c.units[t].fromBase(c.units[f].toBase(v)), places)
}
val valueA: String get() = if (focusA) input else output
val valueB: String get() = if (focusA) output else input
fun press(k: Pad) = if (category != null) convKey(k) else calcKey(k)
fun clearAll() {
if (category != null) enterInput("") else reset()
}
// title tap
fun recall() {
val v = GrainMemory.value ?: return
if (category != null) enterInput(GrainCalc.fmt(v), true)
else insert(GrainCalc.fmt(v, places))
}
fun copy() {
val s = resultText() ?: return
clip?.setPrimaryClip(ClipData.newPlainText(CLIP_LABEL, s))
}
fun paste() {
val item = clip?.primaryClip?.takeIf { it.itemCount > 0 }?.getItemAt(0) ?: return
val raw = item.coerceToText(getApplication()).toString()
val v = GrainCalc.parse(raw) ?: return
val s = GrainCalc.fmt(v)
if (category != null) enterInput(s, true) else insert(s)
}
fun openCategory(c: Category) {
category = c
unitA = c.defA
unitB = c.defB
focusA = true
enterInput("")
}
fun closeCategory() {
val c = category ?: return
val a = GrainCalc.parse(valueA)
val b = GrainCalc.parse(valueB)
if (a != null && b != null && a != 0.0 && b != 0.0) {
log(
"${GrainCalc.fmt(a, places)} ${c.units[unitA].abbr} = " +
"${GrainCalc.fmt(b, places)} ${c.units[unitB].abbr}"
)
}
category = null
enterInput("")
}
fun focus(a: Boolean) {
if (a == focusA) return
val o = output
val prior = input
focusA = a
input = o
fresh = true
pinned = prior
}
fun pickUnit(a: Boolean, i: Int) {
if (a) unitA = i else unitB = i
pinned = null
}
// calc keys
private fun calcKey(k: Pad) {
when (k) {
Pad.CLR -> backspace()
Pad.MEM -> store(if (error) null else GrainCalc.eval(expr))
Pad.PAR -> paren()
Pad.EQ -> solve()
Pad.PCT -> pct()
Pad.DOT -> dot()
Pad.ADD, Pad.SUB, Pad.MUL, Pad.DIV -> op(k.label[0])
else -> digit(k.label[0])
}
}
private fun reset(s: String = "") {
expr = s
solved = false
error = false
}
private fun digit(c: Char) {
if (solved || error) return reset(c.toString())
if (expr.lastOrNull() == ')') return
val t = GrainCalc.tail(expr)
if ('%' in t || 'E' in t) return
expr = if (t == "0") expr.dropLast(1) + c else expr + c
}
private fun dot() {
if (solved || error) return reset("0.")
if (expr.lastOrNull() == ')') return
val t = GrainCalc.tail(expr)
if ('.' in t || 'E' in t || '%' in t) return
expr += if (t.isEmpty()) "0." else "."
}
private fun op(c: Char) {
if (error) return
solved = false
val last = expr.lastOrNull()
val prev = expr.getOrNull(expr.length - 2)
expr = when {
last == null || last == '(' -> if (c == '-') expr + c else return // unary
GrainCalc.isOp(last) -> if (prev == null || prev == '(') return else expr.dropLast(1) + c
else -> expr + c
}
}
// open/close
private fun paren() {
if (solved || error) return reset("(")
val last = expr.lastOrNull()
val operand = last != null && (last.isDigit() || last in ".%)")
expr = when {
GrainCalc.openCount(expr) <= 0 -> expr + if (operand) "×(" else "("
operand -> "$expr)"
else -> return
}
}
private fun pct() {
if (error) return
solved = false
val t = GrainCalc.tail(expr)
if (t.isEmpty() || t == "." || '%' in t) return
expr += "%"
}
private fun backspace() {
if (solved || error) return reset()
expr = expr.dropLast(1)
}
private fun solve() {
if (error || solved) return
val v = GrainCalc.eval(expr) ?: return
val r = GrainCalc.fmt(v, places)
if (r == ERR) {
expr = ""
error = true
return
}
if (GrainCalc.isCompound(expr)) {
log("${GrainCalc.close(expr)} = $r")
}
expr = r
solved = true
}
// replace trailing
private fun insert(s: String) {
if (solved || error || expr.isEmpty()) return reset(s)
val last = expr.last()
expr = if (GrainCalc.isOp(last) || last == '(') expr + s
else if (last == ')') "$expr×$s"
else expr.dropLast(GrainCalc.tail(expr).length).let { if (it == "-") s else it + s }
}
// convert keys
private fun convKey(k: Pad) {
when (k) {
Pad.CLR -> enterInput(if (fresh) "" else input.dropLast(1))
Pad.MEM -> store(GrainCalc.parse(output))
Pad.SUB -> enterInput(
if (input.startsWith("-")) input.drop(1) else "-$input", fresh
)
Pad.DOT -> when {
fresh -> enterInput("0.")
'.' in input -> Unit
input.isEmpty() || input == "-" -> enterInput(input + "0.")
else -> enterInput("$input.")
}
Pad.ADD, Pad.MUL, Pad.DIV, Pad.PCT, Pad.EQ, Pad.PAR -> Unit
else -> {
val c = k.label
enterInput(
when {
fresh || input == "0" -> c
input == "-0" -> "-$c"
else -> input + c
}
)
}
}
}
private fun enterInput(s: String, isFresh: Boolean = false) {
input = s
fresh = isFresh
pinned = null
}
// shared
private fun store(v: Double?) {
v?.takeIf { it.isFinite() }?.let { GrainMemory.value = it }
}
private fun resultText(): String? = when {
category != null -> output.takeIf { it.isNotEmpty() && it != ERR }
error -> null
solved -> expr
else -> GrainCalc.eval(expr)?.let { GrainCalc.fmt(it, places) }?.takeIf { it != ERR }
}
private fun log(line: String) {
viewModelScope.launch { prefs.addHistory(line) }
}
}