From e42fd625a50e4c5976e12c4da5fd05a7489d8c74 Mon Sep 17 00:00:00 2001 From: Sad Ellie Date: Mon, 30 Oct 2023 23:17:36 +0300 Subject: [PATCH] Refactor CalculatorViewModel --- .../calculator/CalculatorScreenTest.kt | 31 ++- .../feature/calculator/CalculatorScreen.kt | 20 +- .../feature/calculator/CalculatorUIState.kt | 34 +-- .../feature/calculator/CalculatorViewModel.kt | 199 +++++++++--------- .../feature/calculator/components/TextBox.kt | 20 +- 5 files changed, 184 insertions(+), 120 deletions(-) diff --git a/feature/calculator/src/androidTest/java/com/sadellie/unitto/feature/calculator/CalculatorScreenTest.kt b/feature/calculator/src/androidTest/java/com/sadellie/unitto/feature/calculator/CalculatorScreenTest.kt index e13fa0eb..9dd18b6a 100644 --- a/feature/calculator/src/androidTest/java/com/sadellie/unitto/feature/calculator/CalculatorScreenTest.kt +++ b/feature/calculator/src/androidTest/java/com/sadellie/unitto/feature/calculator/CalculatorScreenTest.kt @@ -25,7 +25,10 @@ import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performTouchInput import androidx.compose.ui.test.swipeDown +import androidx.compose.ui.text.input.TextFieldValue +import com.sadellie.unitto.core.base.OutputFormat import com.sadellie.unitto.core.base.R +import com.sadellie.unitto.core.ui.common.textfield.FormatterSymbols import org.junit.Rule import org.junit.Test @@ -58,7 +61,19 @@ class CalculatorScreenTest { fun ready_showRealKeyboard(): Unit = with(composeTestRule) { setContent { CalculatorScreen( - uiState = CalculatorUIState.Ready(), + uiState = CalculatorUIState.Ready( + input = TextFieldValue(), + output = CalculationResult.Default(), + radianMode = false, + precision = 3, + outputFormat = OutputFormat.PLAIN, + formatterSymbols = FormatterSymbols.Spaces, + history = emptyList(), + allowVibration = false, + middleZero = false, + acButton = true, + partialHistoryView = true + ), navigateToMenu = {}, navigateToSettings = {}, addTokens = {}, @@ -80,7 +95,19 @@ class CalculatorScreenTest { fun ready_swipeForHistory(): Unit = with(composeTestRule) { setContent { CalculatorScreen( - uiState = CalculatorUIState.Ready(), + uiState = CalculatorUIState.Ready( + input = TextFieldValue(), + output = CalculationResult.Default(), + radianMode = false, + precision = 3, + outputFormat = OutputFormat.PLAIN, + formatterSymbols = FormatterSymbols.Spaces, + history = emptyList(), + allowVibration = false, + middleZero = false, + acButton = true, + partialHistoryView = true + ), navigateToMenu = {}, navigateToSettings = {}, addTokens = {}, diff --git a/feature/calculator/src/main/java/com/sadellie/unitto/feature/calculator/CalculatorScreen.kt b/feature/calculator/src/main/java/com/sadellie/unitto/feature/calculator/CalculatorScreen.kt index b4f64630..e906d883 100644 --- a/feature/calculator/src/main/java/com/sadellie/unitto/feature/calculator/CalculatorScreen.kt +++ b/feature/calculator/src/main/java/com/sadellie/unitto/feature/calculator/CalculatorScreen.kt @@ -21,6 +21,7 @@ package com.sadellie.unitto.feature.calculator import androidx.activity.compose.BackHandler import androidx.compose.animation.Crossfade import androidx.compose.animation.core.tween +import androidx.compose.foundation.background import androidx.compose.foundation.gestures.AnchoredDraggableState import androidx.compose.foundation.gestures.DraggableAnchors import androidx.compose.foundation.gestures.Orientation @@ -61,11 +62,13 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.sadellie.unitto.core.base.OutputFormat import com.sadellie.unitto.core.base.R import com.sadellie.unitto.core.ui.common.MenuButton import com.sadellie.unitto.core.ui.common.SettingsButton import com.sadellie.unitto.core.ui.common.UnittoEmptyScreen import com.sadellie.unitto.core.ui.common.UnittoScreenWithTopBar +import com.sadellie.unitto.core.ui.common.textfield.FormatterSymbols import com.sadellie.unitto.data.model.HistoryItem import com.sadellie.unitto.feature.calculator.components.CalculatorKeyboard import com.sadellie.unitto.feature.calculator.components.HistoryItemHeight @@ -91,7 +94,7 @@ internal fun CalculatorRoute( clearInput = viewModel::clearInput, deleteTokens = viewModel::deleteTokens, onCursorChange = viewModel::onCursorChange, - toggleCalculatorMode = viewModel::toggleCalculatorMode, + toggleCalculatorMode = viewModel::updateRadianMode, evaluate = viewModel::evaluate, clearHistory = viewModel::clearHistory, addBracket = viewModel::addBracket @@ -108,7 +111,7 @@ internal fun CalculatorScreen( clearInput: () -> Unit, deleteTokens: () -> Unit, onCursorChange: (TextRange) -> Unit, - toggleCalculatorMode: () -> Unit, + toggleCalculatorMode: (Boolean) -> Unit, evaluate: () -> Unit, clearHistory: () -> Unit ) { @@ -122,7 +125,7 @@ internal fun CalculatorScreen( clearSymbols = clearInput, deleteSymbol = deleteTokens, onCursorChange = onCursorChange, - toggleAngleMode = toggleCalculatorMode, + toggleAngleMode = { toggleCalculatorMode(!uiState.radianMode) }, evaluate = evaluate, clearHistory = clearHistory, addBracket = addBracket @@ -238,6 +241,7 @@ private fun Ready( HistoryList( modifier = Modifier + .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)) .fillMaxWidth() .height(historyListHeight), historyItems = uiState.history, @@ -350,7 +354,15 @@ private fun PreviewCalculatorScreen() { uiState = CalculatorUIState.Ready( input = TextFieldValue("1.2345"), output = CalculationResult.Default("1234"), - history = historyItems + radianMode = false, + precision = 3, + outputFormat = OutputFormat.PLAIN, + formatterSymbols = FormatterSymbols.Spaces, + history = historyItems, + allowVibration = false, + middleZero = false, + acButton = true, + partialHistoryView = true ), navigateToMenu = {}, navigateToSettings = {}, diff --git a/feature/calculator/src/main/java/com/sadellie/unitto/feature/calculator/CalculatorUIState.kt b/feature/calculator/src/main/java/com/sadellie/unitto/feature/calculator/CalculatorUIState.kt index 862d4b7a..4f30a926 100644 --- a/feature/calculator/src/main/java/com/sadellie/unitto/feature/calculator/CalculatorUIState.kt +++ b/feature/calculator/src/main/java/com/sadellie/unitto/feature/calculator/CalculatorUIState.kt @@ -28,20 +28,30 @@ internal sealed class CalculatorUIState { data object Loading : CalculatorUIState() data class Ready( - val input: TextFieldValue = TextFieldValue(), - val output: CalculationResult = CalculationResult.Default(), - val radianMode: Boolean = true, - val history: List = emptyList(), - val allowVibration: Boolean = false, - val formatterSymbols: FormatterSymbols = FormatterSymbols.Spaces, - val middleZero: Boolean = false, - val acButton: Boolean = false, - val partialHistoryView: Boolean = true, + val input: TextFieldValue, + val output: CalculationResult, + val radianMode: Boolean, + val precision: Int, + val outputFormat: Int, + val formatterSymbols: FormatterSymbols, + val history: List, + val allowVibration: Boolean, + val middleZero: Boolean, + val acButton: Boolean, + val partialHistoryView: Boolean, ) : CalculatorUIState() } -sealed class CalculationResult(@StringRes val label: Int? = null) { +sealed class CalculationResult { data class Default(val text: String = "") : CalculationResult() - data object DivideByZeroError : CalculationResult(R.string.calculator_divide_by_zero_error) - data object Error : CalculationResult(R.string.error_label) + + data object DivideByZeroError : CalculationResult() { + @StringRes + val label: Int = R.string.calculator_divide_by_zero_error + } + + data object Error : CalculationResult() { + @StringRes + val label: Int = R.string.error_label + } } diff --git a/feature/calculator/src/main/java/com/sadellie/unitto/feature/calculator/CalculatorViewModel.kt b/feature/calculator/src/main/java/com/sadellie/unitto/feature/calculator/CalculatorViewModel.kt index e9a8aa6e..7474dad2 100644 --- a/feature/calculator/src/main/java/com/sadellie/unitto/feature/calculator/CalculatorViewModel.kt +++ b/feature/calculator/src/main/java/com/sadellie/unitto/feature/calculator/CalculatorViewModel.kt @@ -20,10 +20,9 @@ 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 import androidx.lifecycle.viewModelScope -import com.sadellie.unitto.core.base.OutputFormat -import com.sadellie.unitto.core.base.Separator import com.sadellie.unitto.core.base.Token import com.sadellie.unitto.core.ui.common.textfield.AllFormatterSymbols import com.sadellie.unitto.core.ui.common.textfield.addBracket @@ -32,21 +31,18 @@ import com.sadellie.unitto.core.ui.common.textfield.deleteTokens import com.sadellie.unitto.data.calculator.CalculatorHistoryRepository import com.sadellie.unitto.data.common.isExpression import com.sadellie.unitto.data.common.setMinimumRequiredScale +import com.sadellie.unitto.data.common.stateIn import com.sadellie.unitto.data.common.toStringWith import com.sadellie.unitto.data.common.trimZeros -import com.sadellie.unitto.data.userprefs.CalculatorPreferences import com.sadellie.unitto.data.userprefs.UserPreferencesRepository import dagger.hilt.android.lifecycle.HiltViewModel import io.github.sadellie.evaluatto.Expression import io.github.sadellie.evaluatto.ExpressionException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.merge -import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.mapLatest import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import java.math.BigDecimal @@ -56,139 +52,146 @@ import javax.inject.Inject internal class CalculatorViewModel @Inject constructor( private val userPrefsRepository: UserPreferencesRepository, private val calculatorHistoryRepository: CalculatorHistoryRepository, + private val savedStateHandle: SavedStateHandle, ) : ViewModel() { - private val _prefs: StateFlow = - userPrefsRepository.calculatorPrefs.stateIn( - viewModelScope, - SharingStarted.WhileSubscribed(5000L), - CalculatorPreferences( - radianMode = false, - enableVibrations = false, - separator = Separator.SPACE, - middleZero = false, - partialHistoryView = true, - precision = 3, - outputFormat = OutputFormat.PLAIN, - acButton = false, - ) - ) - - private val _input: MutableStateFlow = MutableStateFlow(TextFieldValue()) - private val _output: MutableStateFlow = - MutableStateFlow(CalculationResult.Default()) - private val _history = calculatorHistoryRepository.historyFlow + private val _inputKey = "CALCULATOR_INPUT" + private val _input = MutableStateFlow( + with(savedStateHandle[_inputKey] ?: "") { + TextFieldValue(this, TextRange(this.length)) + } + ) + private val _result = MutableStateFlow(CalculationResult.Default()) private val _equalClicked = MutableStateFlow(false) - val uiState = combine( - _input, _output, _history, _prefs - ) { input, output, history, userPrefs -> + val uiState: StateFlow = combine( + _input, + _result, + userPrefsRepository.calculatorPrefs, + calculatorHistoryRepository.historyFlow, + _equalClicked, + ) { input, result, prefs, history, showError -> return@combine CalculatorUIState.Ready( input = input, - output = output, - radianMode = userPrefs.radianMode, + output = if (!showError and (result !is CalculationResult.Default)) CalculationResult.Default() else result, + radianMode = prefs.radianMode, + precision = prefs.precision, + outputFormat = prefs.outputFormat, + formatterSymbols = AllFormatterSymbols.getById(prefs.separator), history = history, - allowVibration = userPrefs.enableVibrations, - formatterSymbols = AllFormatterSymbols.getById(userPrefs.separator), - middleZero = userPrefs.middleZero, - acButton = userPrefs.acButton, - partialHistoryView = userPrefs.partialHistoryView, + allowVibration = prefs.enableVibrations, + middleZero = prefs.middleZero, + acButton = prefs.acButton, + partialHistoryView = prefs.partialHistoryView, ) } - .stateIn( - viewModelScope, SharingStarted.WhileSubscribed(5000L), CalculatorUIState.Loading - ) + .mapLatest { ui -> + calculate( + input = ui.input.text, + radianMode = ui.radianMode, + outputFormat = ui.outputFormat, + precision = ui.precision + ) + + ui + } + .stateIn(viewModelScope, CalculatorUIState.Loading) fun addTokens(tokens: String) = _input.update { - if (_equalClicked.value) { + val newValue = if (_equalClicked.value) { _equalClicked.update { false } TextFieldValue().addTokens(tokens) } else { it.addTokens(tokens) } + savedStateHandle[_inputKey] = newValue.text + newValue } + fun addBracket() = _input.update { - if (_equalClicked.value) { + val newValue = if (_equalClicked.value) { _equalClicked.update { false } TextFieldValue().addBracket() } else { it.addBracket() } + savedStateHandle[_inputKey] = newValue.text + newValue } + fun deleteTokens() = _input.update { - if (_equalClicked.value) { + val newValue = if (_equalClicked.value) { _equalClicked.update { false } TextFieldValue().deleteTokens() } else { it.deleteTokens() } + savedStateHandle[_inputKey] = newValue.text + newValue + } + + fun clearInput() = _input.update { + savedStateHandle[_inputKey] = "" + TextFieldValue() } - fun clearInput() = _input.update { TextFieldValue() } fun onCursorChange(selection: TextRange) = _input.update { it.copy(selection = selection) } - // Called when user clicks "=" on a keyboard - fun evaluate() = viewModelScope.launch(Dispatchers.IO) { - when (val calculationResult = calculateInput()) { - is CalculationResult.Default -> { - if (calculationResult.text.isEmpty()) return@launch - - // We can get negative number and they use ugly minus symbol - val calculationText = calculationResult.text.replace("-", Token.Operator.minus) - - calculatorHistoryRepository.add( - expression = _input.value.text, - result = calculationText - ) - _input.update { - TextFieldValue(calculationText, TextRange(calculationText.length)) - } - _output.update { CalculationResult.Default() } - _equalClicked.update { true } - } - // Show the error - else -> _output.update { calculationResult } - } - } - - fun toggleCalculatorMode() = viewModelScope.launch { - userPrefsRepository.updateRadianMode(!_prefs.value.radianMode) + fun updateRadianMode(newValue: Boolean) = viewModelScope.launch { + userPrefsRepository.updateRadianMode(newValue) } fun clearHistory() = viewModelScope.launch(Dispatchers.IO) { calculatorHistoryRepository.clear() } - private fun calculateInput(): CalculationResult { - val currentInput = _input.value.text - // Input is empty or not an expression, don't calculate - if (!currentInput.isExpression()) return CalculationResult.Default() + fun evaluate() = viewModelScope.launch(Dispatchers.IO) { + when (val result = _result.value) { + is CalculationResult.Default -> { + calculatorHistoryRepository.add( + expression = _input.value.text.replace("-", Token.Operator.minus), + result = result.text + ) + _input.update { TextFieldValue(result.text, TextRange(result.text.length)) } + _result.update { CalculationResult.Default() } + _equalClicked.update { true } + } - return try { - CalculationResult.Default( - Expression(currentInput, radianMode = _prefs.value.radianMode) - .calculate() - .also { - if (it > BigDecimal.valueOf(Double.MAX_VALUE)) throw ExpressionException.TooBig() - } - .setMinimumRequiredScale(_prefs.value.precision) - .trimZeros() - .toStringWith(_prefs.value.outputFormat) - ) - } catch (e: ExpressionException.DivideByZero) { - CalculationResult.DivideByZeroError - } catch (e: Exception) { - CalculationResult.Error + is CalculationResult.DivideByZeroError -> { + _equalClicked.update { true } + } + + is CalculationResult.Error -> { + // skip for generic error (bad expression and stuff + } } } - init { - // Observe and invoke calculation without UI lag. - viewModelScope.launch(Dispatchers.Default) { - merge(_prefs, _input).collectLatest { - val calculated = calculateInput() - _output.update { - // Don't show error when simply entering stuff - if (calculated !is CalculationResult.Default) CalculationResult.Default() else calculated - } + private fun calculate( + input: String, + radianMode: Boolean, + outputFormat: Int, + precision: Int, + ) = viewModelScope.launch(Dispatchers.Default) { + if (!input.isExpression()) { + _result.update { CalculationResult.Default() } + return@launch + } + + _result.update { + try { + CalculationResult.Default( + Expression(input, radianMode = radianMode) + .calculate() + .also { + if (it > BigDecimal.valueOf(Double.MAX_VALUE)) throw ExpressionException.TooBig() + } + .setMinimumRequiredScale(precision) + .trimZeros() + .toStringWith(outputFormat) + ) + } catch (e: ExpressionException.DivideByZero) { + CalculationResult.DivideByZeroError + } catch (e: Exception) { + CalculationResult.Error } } } diff --git a/feature/calculator/src/main/java/com/sadellie/unitto/feature/calculator/components/TextBox.kt b/feature/calculator/src/main/java/com/sadellie/unitto/feature/calculator/components/TextBox.kt index c6e927ba..a1c991f6 100644 --- a/feature/calculator/src/main/java/com/sadellie/unitto/feature/calculator/components/TextBox.kt +++ b/feature/calculator/src/main/java/com/sadellie/unitto/feature/calculator/components/TextBox.kt @@ -102,15 +102,27 @@ fun TextBox( ) } - else -> { - val label = output.label?.let { stringResource(it) } ?: "" - + is CalculationResult.DivideByZeroError -> { UnformattedTextField( modifier = Modifier .weight(2f) .fillMaxWidth() .padding(horizontal = 8.dp), - value = TextFieldValue(label), + value = TextFieldValue(stringResource(output.label)), + minRatio = 0.8f, + onCursorChange = {}, + textColor = MaterialTheme.colorScheme.error, + readOnly = true, + ) + } + + is CalculationResult.Error -> { + UnformattedTextField( + modifier = Modifier + .weight(2f) + .fillMaxWidth() + .padding(horizontal = 8.dp), + value = TextFieldValue(stringResource(output.label)), minRatio = 0.8f, onCursorChange = {}, textColor = MaterialTheme.colorScheme.error,