Refactor CalculatorViewModel

This commit is contained in:
Sad Ellie 2023-10-30 23:17:36 +03:00
parent 48e75093bd
commit e42fd625a5
5 changed files with 184 additions and 120 deletions

View File

@ -25,7 +25,10 @@ import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performTouchInput import androidx.compose.ui.test.performTouchInput
import androidx.compose.ui.test.swipeDown 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.base.R
import com.sadellie.unitto.core.ui.common.textfield.FormatterSymbols
import org.junit.Rule import org.junit.Rule
import org.junit.Test import org.junit.Test
@ -58,7 +61,19 @@ class CalculatorScreenTest {
fun ready_showRealKeyboard(): Unit = with(composeTestRule) { fun ready_showRealKeyboard(): Unit = with(composeTestRule) {
setContent { setContent {
CalculatorScreen( 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 = {}, navigateToMenu = {},
navigateToSettings = {}, navigateToSettings = {},
addTokens = {}, addTokens = {},
@ -80,7 +95,19 @@ class CalculatorScreenTest {
fun ready_swipeForHistory(): Unit = with(composeTestRule) { fun ready_swipeForHistory(): Unit = with(composeTestRule) {
setContent { setContent {
CalculatorScreen( 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 = {}, navigateToMenu = {},
navigateToSettings = {}, navigateToSettings = {},
addTokens = {}, addTokens = {},

View File

@ -21,6 +21,7 @@ package com.sadellie.unitto.feature.calculator
import androidx.activity.compose.BackHandler import androidx.activity.compose.BackHandler
import androidx.compose.animation.Crossfade import androidx.compose.animation.Crossfade
import androidx.compose.animation.core.tween import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.AnchoredDraggableState import androidx.compose.foundation.gestures.AnchoredDraggableState
import androidx.compose.foundation.gestures.DraggableAnchors import androidx.compose.foundation.gestures.DraggableAnchors
import androidx.compose.foundation.gestures.Orientation 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.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.sadellie.unitto.core.base.OutputFormat
import com.sadellie.unitto.core.base.R import com.sadellie.unitto.core.base.R
import com.sadellie.unitto.core.ui.common.MenuButton import com.sadellie.unitto.core.ui.common.MenuButton
import com.sadellie.unitto.core.ui.common.SettingsButton import com.sadellie.unitto.core.ui.common.SettingsButton
import com.sadellie.unitto.core.ui.common.UnittoEmptyScreen import com.sadellie.unitto.core.ui.common.UnittoEmptyScreen
import com.sadellie.unitto.core.ui.common.UnittoScreenWithTopBar 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.data.model.HistoryItem
import com.sadellie.unitto.feature.calculator.components.CalculatorKeyboard import com.sadellie.unitto.feature.calculator.components.CalculatorKeyboard
import com.sadellie.unitto.feature.calculator.components.HistoryItemHeight import com.sadellie.unitto.feature.calculator.components.HistoryItemHeight
@ -91,7 +94,7 @@ internal fun CalculatorRoute(
clearInput = viewModel::clearInput, clearInput = viewModel::clearInput,
deleteTokens = viewModel::deleteTokens, deleteTokens = viewModel::deleteTokens,
onCursorChange = viewModel::onCursorChange, onCursorChange = viewModel::onCursorChange,
toggleCalculatorMode = viewModel::toggleCalculatorMode, toggleCalculatorMode = viewModel::updateRadianMode,
evaluate = viewModel::evaluate, evaluate = viewModel::evaluate,
clearHistory = viewModel::clearHistory, clearHistory = viewModel::clearHistory,
addBracket = viewModel::addBracket addBracket = viewModel::addBracket
@ -108,7 +111,7 @@ internal fun CalculatorScreen(
clearInput: () -> Unit, clearInput: () -> Unit,
deleteTokens: () -> Unit, deleteTokens: () -> Unit,
onCursorChange: (TextRange) -> Unit, onCursorChange: (TextRange) -> Unit,
toggleCalculatorMode: () -> Unit, toggleCalculatorMode: (Boolean) -> Unit,
evaluate: () -> Unit, evaluate: () -> Unit,
clearHistory: () -> Unit clearHistory: () -> Unit
) { ) {
@ -122,7 +125,7 @@ internal fun CalculatorScreen(
clearSymbols = clearInput, clearSymbols = clearInput,
deleteSymbol = deleteTokens, deleteSymbol = deleteTokens,
onCursorChange = onCursorChange, onCursorChange = onCursorChange,
toggleAngleMode = toggleCalculatorMode, toggleAngleMode = { toggleCalculatorMode(!uiState.radianMode) },
evaluate = evaluate, evaluate = evaluate,
clearHistory = clearHistory, clearHistory = clearHistory,
addBracket = addBracket addBracket = addBracket
@ -238,6 +241,7 @@ private fun Ready(
HistoryList( HistoryList(
modifier = Modifier modifier = Modifier
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f))
.fillMaxWidth() .fillMaxWidth()
.height(historyListHeight), .height(historyListHeight),
historyItems = uiState.history, historyItems = uiState.history,
@ -350,7 +354,15 @@ private fun PreviewCalculatorScreen() {
uiState = CalculatorUIState.Ready( uiState = CalculatorUIState.Ready(
input = TextFieldValue("1.2345"), input = TextFieldValue("1.2345"),
output = CalculationResult.Default("1234"), 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 = {}, navigateToMenu = {},
navigateToSettings = {}, navigateToSettings = {},

View File

@ -28,20 +28,30 @@ internal sealed class CalculatorUIState {
data object Loading : CalculatorUIState() data object Loading : CalculatorUIState()
data class Ready( data class Ready(
val input: TextFieldValue = TextFieldValue(), val input: TextFieldValue,
val output: CalculationResult = CalculationResult.Default(), val output: CalculationResult,
val radianMode: Boolean = true, val radianMode: Boolean,
val history: List<HistoryItem> = emptyList(), val precision: Int,
val allowVibration: Boolean = false, val outputFormat: Int,
val formatterSymbols: FormatterSymbols = FormatterSymbols.Spaces, val formatterSymbols: FormatterSymbols,
val middleZero: Boolean = false, val history: List<HistoryItem>,
val acButton: Boolean = false, val allowVibration: Boolean,
val partialHistoryView: Boolean = true, val middleZero: Boolean,
val acButton: Boolean,
val partialHistoryView: Boolean,
) : CalculatorUIState() ) : CalculatorUIState()
} }
sealed class CalculationResult(@StringRes val label: Int? = null) { sealed class CalculationResult {
data class Default(val text: String = "") : 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
}
} }

View File

@ -20,10 +20,9 @@ package com.sadellie.unitto.feature.calculator
import androidx.compose.ui.text.TextRange 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.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope 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.base.Token
import com.sadellie.unitto.core.ui.common.textfield.AllFormatterSymbols import com.sadellie.unitto.core.ui.common.textfield.AllFormatterSymbols
import com.sadellie.unitto.core.ui.common.textfield.addBracket 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.calculator.CalculatorHistoryRepository
import com.sadellie.unitto.data.common.isExpression import com.sadellie.unitto.data.common.isExpression
import com.sadellie.unitto.data.common.setMinimumRequiredScale 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.toStringWith
import com.sadellie.unitto.data.common.trimZeros import com.sadellie.unitto.data.common.trimZeros
import com.sadellie.unitto.data.userprefs.CalculatorPreferences
import com.sadellie.unitto.data.userprefs.UserPreferencesRepository import com.sadellie.unitto.data.userprefs.UserPreferencesRepository
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import io.github.sadellie.evaluatto.Expression 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.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.merge import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.math.BigDecimal import java.math.BigDecimal
@ -56,122 +52,141 @@ import javax.inject.Inject
internal class CalculatorViewModel @Inject constructor( internal class CalculatorViewModel @Inject constructor(
private val userPrefsRepository: UserPreferencesRepository, private val userPrefsRepository: UserPreferencesRepository,
private val calculatorHistoryRepository: CalculatorHistoryRepository, private val calculatorHistoryRepository: CalculatorHistoryRepository,
private val savedStateHandle: SavedStateHandle,
) : ViewModel() { ) : ViewModel() {
private val _prefs: StateFlow<CalculatorPreferences> = private val _inputKey = "CALCULATOR_INPUT"
userPrefsRepository.calculatorPrefs.stateIn( private val _input = MutableStateFlow(
viewModelScope, with(savedStateHandle[_inputKey] ?: "") {
SharingStarted.WhileSubscribed(5000L), TextFieldValue(this, TextRange(this.length))
CalculatorPreferences( }
radianMode = false,
enableVibrations = false,
separator = Separator.SPACE,
middleZero = false,
partialHistoryView = true,
precision = 3,
outputFormat = OutputFormat.PLAIN,
acButton = false,
) )
) private val _result = MutableStateFlow<CalculationResult>(CalculationResult.Default())
private val _input: MutableStateFlow<TextFieldValue> = MutableStateFlow(TextFieldValue())
private val _output: MutableStateFlow<CalculationResult> =
MutableStateFlow(CalculationResult.Default())
private val _history = calculatorHistoryRepository.historyFlow
private val _equalClicked = MutableStateFlow(false) private val _equalClicked = MutableStateFlow(false)
val uiState = combine( val uiState: StateFlow<CalculatorUIState> = combine(
_input, _output, _history, _prefs _input,
) { input, output, history, userPrefs -> _result,
userPrefsRepository.calculatorPrefs,
calculatorHistoryRepository.historyFlow,
_equalClicked,
) { input, result, prefs, history, showError ->
return@combine CalculatorUIState.Ready( return@combine CalculatorUIState.Ready(
input = input, input = input,
output = output, output = if (!showError and (result !is CalculationResult.Default)) CalculationResult.Default() else result,
radianMode = userPrefs.radianMode, radianMode = prefs.radianMode,
precision = prefs.precision,
outputFormat = prefs.outputFormat,
formatterSymbols = AllFormatterSymbols.getById(prefs.separator),
history = history, history = history,
allowVibration = userPrefs.enableVibrations, allowVibration = prefs.enableVibrations,
formatterSymbols = AllFormatterSymbols.getById(userPrefs.separator), middleZero = prefs.middleZero,
middleZero = userPrefs.middleZero, acButton = prefs.acButton,
acButton = userPrefs.acButton, partialHistoryView = prefs.partialHistoryView,
partialHistoryView = userPrefs.partialHistoryView,
) )
} }
.stateIn( .mapLatest { ui ->
viewModelScope, SharingStarted.WhileSubscribed(5000L), CalculatorUIState.Loading 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 { fun addTokens(tokens: String) = _input.update {
if (_equalClicked.value) { val newValue = if (_equalClicked.value) {
_equalClicked.update { false } _equalClicked.update { false }
TextFieldValue().addTokens(tokens) TextFieldValue().addTokens(tokens)
} else { } else {
it.addTokens(tokens) it.addTokens(tokens)
} }
savedStateHandle[_inputKey] = newValue.text
newValue
} }
fun addBracket() = _input.update { fun addBracket() = _input.update {
if (_equalClicked.value) { val newValue = if (_equalClicked.value) {
_equalClicked.update { false } _equalClicked.update { false }
TextFieldValue().addBracket() TextFieldValue().addBracket()
} else { } else {
it.addBracket() it.addBracket()
} }
savedStateHandle[_inputKey] = newValue.text
newValue
} }
fun deleteTokens() = _input.update { fun deleteTokens() = _input.update {
if (_equalClicked.value) { val newValue = if (_equalClicked.value) {
_equalClicked.update { false } _equalClicked.update { false }
TextFieldValue().deleteTokens() TextFieldValue().deleteTokens()
} else { } else {
it.deleteTokens() 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) } fun onCursorChange(selection: TextRange) = _input.update { it.copy(selection = selection) }
// Called when user clicks "=" on a keyboard fun updateRadianMode(newValue: Boolean) = viewModelScope.launch {
fun evaluate() = viewModelScope.launch(Dispatchers.IO) { userPrefsRepository.updateRadianMode(newValue)
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 clearHistory() = viewModelScope.launch(Dispatchers.IO) { fun clearHistory() = viewModelScope.launch(Dispatchers.IO) {
calculatorHistoryRepository.clear() calculatorHistoryRepository.clear()
} }
private fun calculateInput(): CalculationResult { fun evaluate() = viewModelScope.launch(Dispatchers.IO) {
val currentInput = _input.value.text when (val result = _result.value) {
// Input is empty or not an expression, don't calculate is CalculationResult.Default -> {
if (!currentInput.isExpression()) return 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 { is CalculationResult.DivideByZeroError -> {
_equalClicked.update { true }
}
is CalculationResult.Error -> {
// skip for generic error (bad expression and stuff
}
}
}
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( CalculationResult.Default(
Expression(currentInput, radianMode = _prefs.value.radianMode) Expression(input, radianMode = radianMode)
.calculate() .calculate()
.also { .also {
if (it > BigDecimal.valueOf(Double.MAX_VALUE)) throw ExpressionException.TooBig() if (it > BigDecimal.valueOf(Double.MAX_VALUE)) throw ExpressionException.TooBig()
} }
.setMinimumRequiredScale(_prefs.value.precision) .setMinimumRequiredScale(precision)
.trimZeros() .trimZeros()
.toStringWith(_prefs.value.outputFormat) .toStringWith(outputFormat)
) )
} catch (e: ExpressionException.DivideByZero) { } catch (e: ExpressionException.DivideByZero) {
CalculationResult.DivideByZeroError CalculationResult.DivideByZeroError
@ -179,17 +194,5 @@ internal class CalculatorViewModel @Inject constructor(
CalculationResult.Error CalculationResult.Error
} }
} }
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
}
}
}
} }
} }

View File

@ -102,15 +102,27 @@ fun TextBox(
) )
} }
else -> { is CalculationResult.DivideByZeroError -> {
val label = output.label?.let { stringResource(it) } ?: ""
UnformattedTextField( UnformattedTextField(
modifier = Modifier modifier = Modifier
.weight(2f) .weight(2f)
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 8.dp), .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, minRatio = 0.8f,
onCursorChange = {}, onCursorChange = {},
textColor = MaterialTheme.colorScheme.error, textColor = MaterialTheme.colorScheme.error,