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

View File

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

View File

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

View File

@ -18,7 +18,6 @@
package com.sadellie.unitto.feature.calculator package com.sadellie.unitto.feature.calculator
import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.input.TextFieldValue
import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel 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.core.ui.common.textfield.placeCursorAtTheEnd
import com.sadellie.unitto.data.common.format import com.sadellie.unitto.data.common.format
import com.sadellie.unitto.data.common.isExpression 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.common.stateIn
import com.sadellie.unitto.data.model.HistoryItem import com.sadellie.unitto.data.model.HistoryItem
import com.sadellie.unitto.data.model.repository.CalculatorHistoryRepository 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 io.github.sadellie.evaluatto.ExpressionException
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
@ -57,13 +57,13 @@ internal class CalculatorViewModel @Inject constructor(
private val calculatorHistoryRepository: CalculatorHistoryRepository, private val calculatorHistoryRepository: CalculatorHistoryRepository,
private val savedStateHandle: SavedStateHandle, private val savedStateHandle: SavedStateHandle,
) : ViewModel() { ) : ViewModel() {
private var calculationJob: Job? = null
private val inputKey = "CALCULATOR_INPUT" private val inputKey = "CALCULATOR_INPUT"
private val input = MutableStateFlow(savedStateHandle.getTextField(inputKey)) private val input = MutableStateFlow(savedStateHandle.getTextField(inputKey))
private val result = MutableStateFlow<CalculationResult>(CalculationResult.Empty) private val result = MutableStateFlow<CalculationResult>(CalculationResult.Empty)
private val equalClicked = MutableStateFlow(false) private val equalClicked = MutableStateFlow(false)
private val prefs = userPrefsRepository.calculatorPrefs private val prefs = userPrefsRepository.calculatorPrefs.stateIn(viewModelScope, null)
.stateIn(viewModelScope, null)
private var fractionJob: Job? = null
val uiState: StateFlow<CalculatorUIState> = combine( val uiState: StateFlow<CalculatorUIState> = combine(
input, input,
@ -88,33 +88,6 @@ internal class CalculatorViewModel @Inject constructor(
partialHistoryView = prefs.partialHistoryView, 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) .stateIn(viewModelScope, CalculatorUIState.Loading)
fun addTokens(tokens: String) { fun addTokens(tokens: String) {
@ -150,14 +123,15 @@ internal class CalculatorViewModel @Inject constructor(
fun clearInput() = updateInput(TextFieldValue()) fun clearInput() = updateInput(TextFieldValue())
fun updateInput(value: TextFieldValue) { fun updateInput(value: TextFieldValue) {
fractionJob?.cancel()
equalClicked.update { false } equalClicked.update { false }
input.update { value } input.update { value }
savedStateHandle[inputKey] = value.text savedStateHandle[inputKey] = value.text
calculateInput()
} }
fun updateRadianMode(newValue: Boolean) = viewModelScope.launch { fun updateRadianMode(newValue: Boolean) = viewModelScope.launch {
userPrefsRepository.updateRadianMode(newValue) userPrefsRepository.updateRadianMode(newValue)
calculateInput()
} }
fun updateAdditionalButtons(newValue: Boolean) = viewModelScope.launch { fun updateAdditionalButtons(newValue: Boolean) = viewModelScope.launch {
@ -168,54 +142,71 @@ internal class CalculatorViewModel @Inject constructor(
userPrefsRepository.updateInverseMode(newValue) userPrefsRepository.updateInverseMode(newValue)
} }
fun clearHistory() = viewModelScope.launch(Dispatchers.IO) { fun clearHistory() = viewModelScope.launch {
calculatorHistoryRepository.clear() calculatorHistoryRepository.clear()
} }
fun deleteHistoryItem(item: HistoryItem) = viewModelScope.launch(Dispatchers.IO) { fun deleteHistoryItem(item: HistoryItem) = viewModelScope.launch {
calculatorHistoryRepository.delete(item) calculatorHistoryRepository.delete(item)
} }
fun equal() = viewModelScope.launch { fun equal() = viewModelScope.launch {
val prefs = prefs.value ?: return@launch val prefs = prefs.value ?: return@launch
val inputValue = input.value.text
if (equalClicked.value) return@launch if (equalClicked.value) return@launch
if (!input.value.text.isExpression()) return@launch if (!inputValue.isExpression()) return@launch
val result = try { val calculated = try {
calculate(input.value.text, prefs.radianMode, RoundingMode.DOWN) calculate(inputValue, prefs.radianMode, RoundingMode.HALF_EVEN)
.format(prefs.precision, prefs.outputFormat)
} catch (e: ExpressionException.DivideByZero) { } catch (e: ExpressionException.DivideByZero) {
equalClicked.update { true }
result.update { CalculationResult.DivideByZeroError } result.update { CalculationResult.DivideByZeroError }
return@launch return@launch
} catch (e: ExpressionException.FactorialCalculation) {
equalClicked.update { true }
result.update { CalculationResult.Error }
return@launch
} catch (e: Exception) { } catch (e: Exception) {
equalClicked.update { true }
result.update { CalculationResult.Error } result.update { CalculationResult.Error }
return@launch return@launch
} }
val fractional = try {
calculate(inputValue, prefs.radianMode, RoundingMode.DOWN)
.toFractionalString()
} catch (e: Exception) {
result.update { CalculationResult.Error }
return@launch
}
calculatorHistoryRepository.add(
expression = inputValue,
result = calculated,
)
equalClicked.update { true } equalClicked.update { true }
input.update { TextFieldValue(calculated.replace("-", Token.Operator.minus)) }
result.update { CalculationResult.Success(fractional) }
}
val resultFormatted = result private fun calculateInput() {
.format(prefs.precision, prefs.outputFormat) calculationJob?.cancel()
.replace("-", Token.Operator.minus) calculationJob = viewModelScope.launch {
if (!input.value.text.isExpression()) {
result.update { CalculationResult.Empty }
return@launch
}
withContext(Dispatchers.IO) { val prefs = prefs.value ?: return@launch
calculatorHistoryRepository.add( val newResult = try {
expression = input.value.text.replace("-", Token.Operator.minus), val calculated = calculate(
result = resultFormatted, input = input.value.text,
) radianMode = prefs.radianMode,
} roundingMode = RoundingMode.HALF_EVEN,
)
fractionJob?.cancel() CalculationResult.Success(
fractionJob = launch(Dispatchers.Default) { calculated.format(prefs.precision, prefs.outputFormat),
val fraction = result.toFractionalString() )
} catch (e: Exception) {
input.update { TextFieldValue(resultFormatted, TextRange.Zero) } CalculationResult.Empty
this@CalculatorViewModel.result.update { CalculationResult.Fraction(fraction) } }
result.update { newResult }
} }
} }
@ -227,7 +218,14 @@ internal class CalculatorViewModel @Inject constructor(
Expression(input, radianMode, roundingMode) Expression(input, radianMode, roundingMode)
.calculate() .calculate()
.also { .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.core.base.Token
import com.sadellie.unitto.data.common.isEqualTo import com.sadellie.unitto.data.common.isEqualTo
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.math.BigDecimal import java.math.BigDecimal
import java.math.BigInteger import java.math.BigInteger
import java.math.RoundingMode import java.math.RoundingMode
@ -36,13 +38,13 @@ import java.math.RoundingMode
* @receiver [BigDecimal]. Scale doesn't matter, but should be `MAX_PRECISION` * @receiver [BigDecimal]. Scale doesn't matter, but should be `MAX_PRECISION`
* @return * @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-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 // 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() 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 " val res: String = if (integral.isEqualTo(BigDecimal.ZERO)) "" else "$integralBI "
@ -54,9 +56,9 @@ fun BigDecimal.toFractionalString(): String {
fractional.repeatingFractional(repeatingDecimals.length) 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> { private fun BigDecimal.notRepeatingFractional(): Pair<BigInteger, BigInteger> {

View File

@ -93,21 +93,7 @@ fun TextBox(
) )
} }
is CalculationResult.Default -> { is CalculationResult.Success -> {
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 -> {
var outputTF by remember(output) { mutableStateOf(TextFieldValue(output.text)) } var outputTF by remember(output) { mutableStateOf(TextFieldValue(output.text)) }
ExpressionTextField( ExpressionTextField(

View File

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