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.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 = {},

View File

@ -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 = {},

View File

@ -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<HistoryItem> = 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<HistoryItem>,
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
}
}

View File

@ -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<CalculatorPreferences> =
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<TextFieldValue> = MutableStateFlow(TextFieldValue())
private val _output: MutableStateFlow<CalculationResult> =
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>(CalculationResult.Default())
private val _equalClicked = MutableStateFlow(false)
val uiState = combine(
_input, _output, _history, _prefs
) { input, output, history, userPrefs ->
val uiState: StateFlow<CalculatorUIState> = 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
}
}
}

View File

@ -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,