Refactor Calculator

- no longer abusing mapLatest and StateFlow
This commit is contained in:
Sad Ellie 2024-02-28 22:08:44 +03:00
parent eb96868afc
commit d34c01ed6a
7 changed files with 87 additions and 109 deletions

View File

@ -26,6 +26,7 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.withContext
import java.util.Date
import javax.inject.Inject
@ -43,7 +44,7 @@ class CalculatorHistoryRepositoryImpl @Inject constructor(
override suspend fun add(
expression: String,
result: String,
) {
) = withContext(Dispatchers.IO) {
calculatorHistoryDao.insert(
CalculatorHistoryEntity(
timestamp = System.currentTimeMillis(),
@ -53,16 +54,16 @@ class CalculatorHistoryRepositoryImpl @Inject constructor(
)
}
override suspend fun delete(item: HistoryItem) {
override suspend fun delete(item: HistoryItem) = withContext(Dispatchers.IO) {
calculatorHistoryDao.delete(item.id)
}
override suspend fun clear() {
override suspend fun clear() = withContext(Dispatchers.IO) {
calculatorHistoryDao.clear()
}
private fun List<CalculatorHistoryEntity>.toHistoryItemList(): List<HistoryItem> {
return this.map {
private suspend fun List<CalculatorHistoryEntity>.toHistoryItemList(): List<HistoryItem> = withContext(Dispatchers.Default) {
this@toHistoryItemList.map {
HistoryItem(
id = it.entityId,
date = Date(it.timestamp),

View File

@ -332,7 +332,7 @@ private fun PreviewCalculatorScreen() {
Ready(
uiState = CalculatorUIState.Ready(
input = TextFieldValue("1.2345"),
output = CalculationResult.Default("1234"),
output = CalculationResult.Success("1234"),
radianMode = false,
precision = 3,
outputFormat = OutputFormat.PLAIN,

View File

@ -42,9 +42,7 @@ internal sealed class CalculatorUIState {
}
sealed class CalculationResult {
data class Default(val text: String) : CalculationResult()
data class Fraction(val text: String) : CalculationResult()
data class Success(val text: String) : CalculationResult()
data object Empty : CalculationResult()

View File

@ -18,7 +18,6 @@
package com.sadellie.unitto.feature.calculator
import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.input.TextFieldValue
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
@ -31,6 +30,7 @@ import com.sadellie.unitto.core.ui.common.textfield.getTextField
import com.sadellie.unitto.core.ui.common.textfield.placeCursorAtTheEnd
import com.sadellie.unitto.data.common.format
import com.sadellie.unitto.data.common.isExpression
import com.sadellie.unitto.data.common.isGreaterThan
import com.sadellie.unitto.data.common.stateIn
import com.sadellie.unitto.data.model.HistoryItem
import com.sadellie.unitto.data.model.repository.CalculatorHistoryRepository
@ -40,10 +40,10 @@ import io.github.sadellie.evaluatto.Expression
import io.github.sadellie.evaluatto.ExpressionException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@ -57,13 +57,13 @@ internal class CalculatorViewModel @Inject constructor(
private val calculatorHistoryRepository: CalculatorHistoryRepository,
private val savedStateHandle: SavedStateHandle,
) : ViewModel() {
private var calculationJob: Job? = null
private val inputKey = "CALCULATOR_INPUT"
private val input = MutableStateFlow(savedStateHandle.getTextField(inputKey))
private val result = MutableStateFlow<CalculationResult>(CalculationResult.Empty)
private val equalClicked = MutableStateFlow(false)
private val prefs = userPrefsRepository.calculatorPrefs
.stateIn(viewModelScope, null)
private var fractionJob: Job? = null
private val prefs = userPrefsRepository.calculatorPrefs.stateIn(viewModelScope, null)
val uiState: StateFlow<CalculatorUIState> = combine(
input,
@ -87,33 +87,6 @@ internal class CalculatorViewModel @Inject constructor(
inverseMode = prefs.inverseMode,
partialHistoryView = prefs.partialHistoryView,
)
}
.mapLatest { ui ->
if (ui !is CalculatorUIState.Ready) return@mapLatest ui
if (equalClicked.value) return@mapLatest ui
if (!ui.input.text.isExpression()) {
result.update { CalculationResult.Empty }
return@mapLatest ui
}
result.update {
try {
CalculationResult.Default(
calculate(
input = ui.input.text,
radianMode = ui.radianMode,
)
.format(ui.precision, ui.outputFormat),
)
} catch (e: ExpressionException.DivideByZero) {
CalculationResult.Empty
} catch (e: Exception) {
CalculationResult.Empty
}
}
ui
}
.stateIn(viewModelScope, CalculatorUIState.Loading)
@ -150,14 +123,15 @@ internal class CalculatorViewModel @Inject constructor(
fun clearInput() = updateInput(TextFieldValue())
fun updateInput(value: TextFieldValue) {
fractionJob?.cancel()
equalClicked.update { false }
input.update { value }
savedStateHandle[inputKey] = value.text
calculateInput()
}
fun updateRadianMode(newValue: Boolean) = viewModelScope.launch {
userPrefsRepository.updateRadianMode(newValue)
calculateInput()
}
fun updateAdditionalButtons(newValue: Boolean) = viewModelScope.launch {
@ -168,54 +142,71 @@ internal class CalculatorViewModel @Inject constructor(
userPrefsRepository.updateInverseMode(newValue)
}
fun clearHistory() = viewModelScope.launch(Dispatchers.IO) {
fun clearHistory() = viewModelScope.launch {
calculatorHistoryRepository.clear()
}
fun deleteHistoryItem(item: HistoryItem) = viewModelScope.launch(Dispatchers.IO) {
fun deleteHistoryItem(item: HistoryItem) = viewModelScope.launch {
calculatorHistoryRepository.delete(item)
}
fun equal() = viewModelScope.launch {
val prefs = prefs.value ?: return@launch
val inputValue = input.value.text
if (equalClicked.value) return@launch
if (!input.value.text.isExpression()) return@launch
if (!inputValue.isExpression()) return@launch
val result = try {
calculate(input.value.text, prefs.radianMode, RoundingMode.DOWN)
val calculated = try {
calculate(inputValue, prefs.radianMode, RoundingMode.HALF_EVEN)
.format(prefs.precision, prefs.outputFormat)
} catch (e: ExpressionException.DivideByZero) {
equalClicked.update { true }
result.update { CalculationResult.DivideByZeroError }
return@launch
} catch (e: ExpressionException.FactorialCalculation) {
equalClicked.update { true }
result.update { CalculationResult.Error }
return@launch
} catch (e: Exception) {
equalClicked.update { true }
result.update { CalculationResult.Error }
return@launch
}
equalClicked.update { true }
val resultFormatted = result
.format(prefs.precision, prefs.outputFormat)
.replace("-", Token.Operator.minus)
withContext(Dispatchers.IO) {
calculatorHistoryRepository.add(
expression = input.value.text.replace("-", Token.Operator.minus),
result = resultFormatted,
)
val fractional = try {
calculate(inputValue, prefs.radianMode, RoundingMode.DOWN)
.toFractionalString()
} catch (e: Exception) {
result.update { CalculationResult.Error }
return@launch
}
fractionJob?.cancel()
fractionJob = launch(Dispatchers.Default) {
val fraction = result.toFractionalString()
calculatorHistoryRepository.add(
expression = inputValue,
result = calculated,
)
input.update { TextFieldValue(resultFormatted, TextRange.Zero) }
this@CalculatorViewModel.result.update { CalculationResult.Fraction(fraction) }
equalClicked.update { true }
input.update { TextFieldValue(calculated.replace("-", Token.Operator.minus)) }
result.update { CalculationResult.Success(fractional) }
}
private fun calculateInput() {
calculationJob?.cancel()
calculationJob = viewModelScope.launch {
if (!input.value.text.isExpression()) {
result.update { CalculationResult.Empty }
return@launch
}
val prefs = prefs.value ?: return@launch
val newResult = try {
val calculated = calculate(
input = input.value.text,
radianMode = prefs.radianMode,
roundingMode = RoundingMode.HALF_EVEN,
)
CalculationResult.Success(
calculated.format(prefs.precision, prefs.outputFormat),
)
} catch (e: Exception) {
CalculationResult.Empty
}
result.update { newResult }
}
}
@ -227,7 +218,14 @@ internal class CalculatorViewModel @Inject constructor(
Expression(input, radianMode, roundingMode)
.calculate()
.also {
if (it > BigDecimal.valueOf(Double.MAX_VALUE)) throw ExpressionException.TooBig()
if (it.isGreaterThan(maxCalculationResult)) throw ExpressionException.TooBig()
}
}
private val maxCalculationResult = BigDecimal.valueOf(Double.MAX_VALUE)
override fun onCleared() {
viewModelScope.cancel()
super.onCleared()
}
}

View File

@ -20,6 +20,8 @@ package com.sadellie.unitto.feature.calculator
import com.sadellie.unitto.core.base.Token
import com.sadellie.unitto.data.common.isEqualTo
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.math.BigDecimal
import java.math.BigInteger
import java.math.RoundingMode
@ -36,13 +38,13 @@ import java.math.RoundingMode
* @receiver [BigDecimal]. Scale doesn't matter, but should be `MAX_PRECISION`
* @return
*/
fun BigDecimal.toFractionalString(): String {
suspend fun BigDecimal.toFractionalString(): String = withContext(Dispatchers.Default) {
// https://www.khanacademy.org/math/cc-eighth-grade-math/cc-8th-numbers-operations/cc-8th-repeating-decimals/v/coverting-repeating-decimals-to-fractions-1
// https://www.khanacademy.org/math/cc-eighth-grade-math/cc-8th-numbers-operations/cc-8th-repeating-decimals/v/coverting-repeating-decimals-to-fractions-2
val (integral, fractional) = this.divideAndRemainder(BigDecimal.ONE)
val (integral, fractional) = this@toFractionalString.divideAndRemainder(BigDecimal.ONE)
val integralBI = integral.toBigInteger()
if (fractional.isEqualTo(BigDecimal.ZERO)) return ""
if (fractional.isEqualTo(BigDecimal.ZERO)) return@withContext ""
val res: String = if (integral.isEqualTo(BigDecimal.ZERO)) "" else "$integralBI "
@ -54,9 +56,9 @@ fun BigDecimal.toFractionalString(): String {
fractional.repeatingFractional(repeatingDecimals.length)
}
if (finalDenominator > maxDenominator) return ""
if (finalDenominator > maxDenominator) return@withContext ""
return "$res$finalNumerator$finalDenominator"
return@withContext "$res$finalNumerator$finalDenominator"
}
private fun BigDecimal.notRepeatingFractional(): Pair<BigInteger, BigInteger> {

View File

@ -93,21 +93,7 @@ fun TextBox(
)
}
is CalculationResult.Default -> {
var outputTF by remember(output) { mutableStateOf(TextFieldValue(output.text)) }
ExpressionTextField(
modifier = calculationResultModifier,
value = outputTF,
minRatio = 0.8f,
onValueChange = { outputTF = it },
textColor = MaterialTheme.colorScheme.onSurfaceVariant.copy(0.6f),
formatterSymbols = formatterSymbols,
readOnly = true,
)
}
is CalculationResult.Fraction -> {
is CalculationResult.Success -> {
var outputTF by remember(output) { mutableStateOf(TextFieldValue(output.text)) }
ExpressionTextField(

View File

@ -18,6 +18,7 @@
package com.sadellie.unitto.feature.calculator
import kotlinx.coroutines.runBlocking
import org.junit.Assert.assertEquals
import org.junit.Test
import java.math.BigDecimal
@ -25,60 +26,52 @@ import java.math.BigDecimal
class DecimalToFractionTest {
@Test
fun testNoDecimal1() {
val bd = BigDecimal("100")
assertFractional("", bd.toFractionalString())
assertFractional("", "100")
}
@Test
fun testNoDecimal2() {
val bd = BigDecimal("100.000000000")
assertFractional("", bd.toFractionalString())
assertFractional("", "100.000000000")
}
@Test
fun testSimpleDecimal1() {
val bd = BigDecimal("0.25")
assertFractional("1/4", bd.toFractionalString())
assertFractional("1/4", "0.25")
}
@Test
fun testSimpleDecimal2() {
val bd = BigDecimal("100.25")
assertFractional("100 1/4", bd.toFractionalString())
assertFractional("100 1/4", "100.25")
}
@Test
fun testRepeating1() {
val bd = BigDecimal("0.666666666")
assertFractional("2/3", bd.toFractionalString())
assertFractional("2/3", "0.666666666")
}
@Test
fun testRepeating2() {
val bd = BigDecimal("4.666666666")
assertFractional("4 2/3", bd.toFractionalString())
assertFractional("4 2/3", "4.666666666")
}
@Test
fun testRepeating3() {
val bd = BigDecimal("0.78571428571428571428")
assertFractional("11/14", bd.toFractionalString())
assertFractional("11/14", "0.78571428571428571428")
}
@Test
fun testRepeating4() {
val bd = BigDecimal("66.78571428571428571428")
assertFractional("66 11/14", bd.toFractionalString())
assertFractional("66 11/14", "66.78571428571428571428")
}
@Test
fun testRepeating5() {
val bd = BigDecimal("0.666000")
assertFractional("333/500", bd.toFractionalString())
assertFractional("333/500", "0.666000")
}
private fun assertFractional(expected: String, actual: String) = assertEquals(
expected,
actual.replace("", "/"),
runBlocking { BigDecimal(actual).toFractionalString() }
.replace("", "/"),
)
}