mirror of
https://github.com/Myzel394/NumberHub.git
synced 2025-06-18 16:25:27 +02:00
Refactor CalculatorViewModel
This commit is contained in:
parent
48e75093bd
commit
e42fd625a5
@ -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 = {},
|
||||
|
@ -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 = {},
|
||||
|
@ -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
|
||||
}
|
||||
}
|
||||
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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,
|
||||
|
Loading…
x
Reference in New Issue
Block a user