Big viewmodel refactor

(squashed)
This commit is contained in:
Sad Ellie 2023-01-03 21:33:54 +04:00
parent b1c8780fc1
commit 78f9d59fd8
13 changed files with 757 additions and 705 deletions

View File

@ -1,55 +0,0 @@
package com.sadellie.unitto.screen
import android.content.Context
import androidx.room.Room
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.sadellie.unitto.data.preferences.DataStoreModule
import com.sadellie.unitto.data.preferences.UserPreferencesRepository
import com.sadellie.unitto.data.units.AllUnitsRepository
import com.sadellie.unitto.data.units.MyUnitIDS
import com.sadellie.unitto.data.units.database.MyBasedUnitDatabase
import com.sadellie.unitto.data.units.database.MyBasedUnitsRepository
import com.sadellie.unitto.screens.main.MainViewModel
import org.junit.Assert.assertEquals
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class SwapUnitsTest {
private lateinit var viewModel: MainViewModel
private val allUnitsRepository = AllUnitsRepository()
@Before
fun setUp() {
val context = ApplicationProvider.getApplicationContext<Context>()
viewModel = MainViewModel(
UserPreferencesRepository(DataStoreModule().provideUserPreferencesDataStore(context)),
MyBasedUnitsRepository(
Room.inMemoryDatabaseBuilder(
context,
MyBasedUnitDatabase::class.java
).build().myBasedUnitDao()
),
ApplicationProvider.getApplicationContext(),
allUnitsRepository
)
}
@Test
fun swapUnits() {
val mile = allUnitsRepository.getById(MyUnitIDS.mile)
val kilometer = allUnitsRepository.getById(MyUnitIDS.kilometer)
viewModel.changeUnitFrom(kilometer)
viewModel.changeUnitTo(mile)
viewModel.swapUnits()
assertEquals(mile, viewModel.unitFrom)
assertEquals(kilometer,viewModel.unitTo)
}
}

View File

@ -25,6 +25,7 @@ import androidx.compose.animation.core.tween
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.SideEffect import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.collectAsState
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavGraphBuilder import androidx.navigation.NavGraphBuilder
@ -122,30 +123,36 @@ fun UnittoApp(
} }
composable(LEFT_LIST_SCREEN) { composable(LEFT_LIST_SCREEN) {
val mainUiState = mainViewModel.uiStateFlow.collectAsState()
val unitFrom = mainUiState.value.unitFrom ?: return@composable
// Initial group // Initial group
secondViewModel.setSelectedChip(mainViewModel.unitFrom.group, true) secondViewModel.setSelectedChip(unitFrom.group, true)
LeftSideScreen( LeftSideScreen(
viewModel = secondViewModel, viewModel = secondViewModel,
currentUnit = mainViewModel.unitFrom, currentUnit = unitFrom,
navigateUp = { navController.navigateUp() }, navigateUp = { navController.navigateUp() },
navigateToSettingsAction = { navController.navigate(UNIT_GROUPS_SCREEN) }, navigateToSettingsAction = { navController.navigate(UNIT_GROUPS_SCREEN) },
selectAction = { mainViewModel.changeUnitFrom(it) } selectAction = { mainViewModel.updateUnitFrom(it) }
) )
} }
composable(RIGHT_LIST_SCREEN) { composable(RIGHT_LIST_SCREEN) {
val mainUiState = mainViewModel.uiStateFlow.collectAsState()
val unitFrom = mainUiState.value.unitFrom ?: return@composable
val unitTo = mainUiState.value.unitTo ?: return@composable
// Initial group // Initial group
secondViewModel.setSelectedChip(mainViewModel.unitFrom.group, false) secondViewModel.setSelectedChip(unitFrom.group, false)
RightSideScreen( RightSideScreen(
viewModel = secondViewModel, viewModel = secondViewModel,
currentUnit = mainViewModel.unitTo, currentUnit = unitTo,
navigateUp = { navController.navigateUp() }, navigateUp = { navController.navigateUp() },
navigateToSettingsAction = { navController.navigate(UNIT_GROUPS_SCREEN) }, navigateToSettingsAction = { navController.navigate(UNIT_GROUPS_SCREEN) },
selectAction = { mainViewModel.changeUnitTo(it) }, selectAction = { mainViewModel.updateUnitTo(it) },
inputValue = mainViewModel.inputValue(), inputValue = mainViewModel.getInputValue(),
unitFrom = mainViewModel.unitFrom unitFrom = unitFrom
) )
} }

View File

@ -21,16 +21,17 @@ package com.sadellie.unitto.data
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
@Suppress("UNCHECKED_CAST") @Suppress("UNCHECKED_CAST")
fun <T1, T2, T3, T4, T5, T6, R> combine( fun <T1, T2, T3, T4, T5, T6, T7, R> combine(
flow: Flow<T1>, flow: Flow<T1>,
flow2: Flow<T2>, flow2: Flow<T2>,
flow3: Flow<T3>, flow3: Flow<T3>,
flow4: Flow<T4>, flow4: Flow<T4>,
flow5: Flow<T5>, flow5: Flow<T5>,
flow6: Flow<T6>, flow6: Flow<T6>,
transform: suspend (T1, T2, T3, T4, T5, T6) -> R flow7: Flow<T7>,
transform: suspend (T1, T2, T3, T4, T5, T6, T7) -> R
): Flow<R> = ): Flow<R> =
kotlinx.coroutines.flow.combine(flow, flow2, flow3, flow4, flow5, flow6) { args: Array<*> -> kotlinx.coroutines.flow.combine(flow, flow2, flow3, flow4, flow5, flow6, flow7) { args: Array<*> ->
transform( transform(
args[0] as T1, args[0] as T1,
args[1] as T2, args[1] as T2,
@ -38,5 +39,6 @@ fun <T1, T2, T3, T4, T5, T6, R> combine(
args[3] as T4, args[3] as T4,
args[4] as T5, args[4] as T5,
args[5] as T6, args[5] as T6,
args[6] as T7,
) )
} }

View File

@ -18,6 +18,7 @@
package com.sadellie.unitto.screens package com.sadellie.unitto.screens
import com.sadellie.unitto.data.INTERNAL_DISPLAY
import com.sadellie.unitto.data.KEY_COMMA import com.sadellie.unitto.data.KEY_COMMA
import com.sadellie.unitto.data.KEY_DOT import com.sadellie.unitto.data.KEY_DOT
import com.sadellie.unitto.data.KEY_E import com.sadellie.unitto.data.KEY_E
@ -81,6 +82,10 @@ object Formatter {
output = output.replace(it, formatNumber(it)) output = output.replace(it, formatNumber(it))
} }
INTERNAL_DISPLAY.forEach {
output = output.replace(it.key, it.value)
}
return output return output
} }

View File

@ -39,8 +39,6 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import com.sadellie.unitto.R import com.sadellie.unitto.R
import com.sadellie.unitto.data.NavRoutes.SETTINGS_SCREEN import com.sadellie.unitto.data.NavRoutes.SETTINGS_SCREEN
import com.sadellie.unitto.data.units.AbstractUnit
import com.sadellie.unitto.data.units.UnitGroup
import com.sadellie.unitto.screens.common.AnimatedTopBarText import com.sadellie.unitto.screens.common.AnimatedTopBarText
import com.sadellie.unitto.screens.main.components.Keyboard import com.sadellie.unitto.screens.main.components.Keyboard
import com.sadellie.unitto.screens.main.components.TopScreenPart import com.sadellie.unitto.screens.main.components.TopScreenPart
@ -52,7 +50,7 @@ fun MainScreen(
viewModel: MainViewModel = viewModel() viewModel: MainViewModel = viewModel()
) { ) {
var launched: Boolean by rememberSaveable { mutableStateOf(false) } var launched: Boolean by rememberSaveable { mutableStateOf(false) }
val mainScreenUIState = viewModel.mainFlow.collectAsStateWithLifecycle() val mainScreenUIState = viewModel.uiStateFlow.collectAsStateWithLifecycle()
Scaffold( Scaffold(
modifier = Modifier, modifier = Modifier,
@ -76,15 +74,12 @@ fun MainScreen(
content = { padding -> content = { padding ->
MainScreenContent( MainScreenContent(
modifier = Modifier.padding(padding), modifier = Modifier.padding(padding),
unitFrom = viewModel.unitFrom,
unitTo = viewModel.unitTo,
mainScreenUIState = mainScreenUIState.value, mainScreenUIState = mainScreenUIState.value,
navControllerAction = { navControllerAction(it) }, navControllerAction = { navControllerAction(it) },
swapMeasurements = { viewModel.swapUnits() }, swapMeasurements = { viewModel.swapUnits() },
processInput = { viewModel.processInput(it) }, processInput = { viewModel.processInput(it) },
deleteDigit = { viewModel.deleteDigit() }, deleteDigit = { viewModel.deleteDigit() },
clearInput = { viewModel.clearInput() }, clearInput = { viewModel.clearInput() },
baseConverterMode = viewModel.unitFrom.group == UnitGroup.NUMBER_BASE
) )
} }
) )
@ -103,15 +98,12 @@ fun MainScreen(
@Composable @Composable
private fun MainScreenContent( private fun MainScreenContent(
modifier: Modifier, modifier: Modifier,
unitFrom: AbstractUnit, mainScreenUIState: MainScreenUIState,
unitTo: AbstractUnit,
mainScreenUIState: MainScreenUIState = MainScreenUIState(),
navControllerAction: (String) -> Unit = {}, navControllerAction: (String) -> Unit = {},
swapMeasurements: () -> Unit = {}, swapMeasurements: () -> Unit = {},
processInput: (String) -> Unit = {}, processInput: (String) -> Unit = {},
deleteDigit: () -> Unit = {}, deleteDigit: () -> Unit = {},
clearInput: () -> Unit = {}, clearInput: () -> Unit = {},
baseConverterMode: Boolean,
) { ) {
PortraitLandscape( PortraitLandscape(
modifier = modifier, modifier = modifier,
@ -121,14 +113,13 @@ private fun MainScreenContent(
inputValue = mainScreenUIState.inputValue, inputValue = mainScreenUIState.inputValue,
calculatedValue = mainScreenUIState.calculatedValue, calculatedValue = mainScreenUIState.calculatedValue,
outputValue = mainScreenUIState.resultValue, outputValue = mainScreenUIState.resultValue,
unitFrom = unitFrom, unitFrom = mainScreenUIState.unitFrom,
unitTo = unitTo, unitTo = mainScreenUIState.unitTo,
loadingDatabase = mainScreenUIState.isLoadingDatabase, loadingNetwork = mainScreenUIState.showLoading,
loadingNetwork = mainScreenUIState.isLoadingNetwork,
networkError = mainScreenUIState.showError, networkError = mainScreenUIState.showError,
onUnitSelectionClick = navControllerAction, onUnitSelectionClick = navControllerAction,
swapUnits = swapMeasurements, swapUnits = swapMeasurements,
baseConverterMode = baseConverterMode, converterMode = mainScreenUIState.mode,
) )
}, },
content2 = { content2 = {
@ -137,7 +128,7 @@ private fun MainScreenContent(
addDigit = processInput, addDigit = processInput,
deleteDigit = deleteDigit, deleteDigit = deleteDigit,
clearInput = clearInput, clearInput = clearInput,
baseConverter = baseConverterMode, converterMode = mainScreenUIState.mode,
) )
} }
) )

View File

@ -19,24 +19,33 @@
package com.sadellie.unitto.screens.main package com.sadellie.unitto.screens.main
import com.sadellie.unitto.data.KEY_0 import com.sadellie.unitto.data.KEY_0
import com.sadellie.unitto.data.units.AbstractUnit
/** /**
* Represents current state of the MainScreen * Represents current state of the MainScreen
* *
* @property inputValue Current input value. Can be expression or a simple number. * @property inputValue Current input value. Can be expression or a simple number.
* @property resultValue Current output value
* @property calculatedValue Currently calculated value. Can be null if not needed (same as input or * @property calculatedValue Currently calculated value. Can be null if not needed (same as input or
* expression in input is invalid) * expression in input is invalid).
* @property isLoadingDatabase Whether we are loading data from Database. Need on app launch, once * @property resultValue Current output value.
* we are done loading list on Units list can be sorted by usage properly/ * @property showLoading Whether we are loading data from network.
* @property isLoadingNetwork Whether we are loading data from network
* @property showError Whether there was an error while loading data from network * @property showError Whether there was an error while loading data from network
* @property unitFrom Unit on the left.
* @property unitTo Unit on the right.
* @property mode
*/ */
data class MainScreenUIState( data class MainScreenUIState(
var inputValue: String = KEY_0, val inputValue: String = KEY_0,
var resultValue: String = KEY_0, val calculatedValue: String? = null,
var isLoadingDatabase: Boolean = true, val resultValue: String = KEY_0,
var isLoadingNetwork: Boolean = false, val showLoading: Boolean = true,
var showError: Boolean = false, val showError: Boolean = false,
var calculatedValue: String? = null val unitFrom: AbstractUnit? = null,
val unitTo: AbstractUnit? = null,
val mode: ConverterMode = ConverterMode.DEFAULT,
) )
enum class ConverterMode {
DEFAULT,
BASE,
}

View File

@ -18,17 +18,12 @@
package com.sadellie.unitto.screens.main package com.sadellie.unitto.screens.main
import android.app.Application
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.github.keelar.exprk.ExpressionException import com.github.keelar.exprk.ExpressionException
import com.github.keelar.exprk.Expressions import com.github.keelar.exprk.Expressions
import com.sadellie.unitto.FirebaseHelper import com.sadellie.unitto.FirebaseHelper
import com.sadellie.unitto.data.DIGITS import com.sadellie.unitto.data.DIGITS
import com.sadellie.unitto.data.INTERNAL_DISPLAY
import com.sadellie.unitto.data.KEY_0 import com.sadellie.unitto.data.KEY_0
import com.sadellie.unitto.data.KEY_1 import com.sadellie.unitto.data.KEY_1
import com.sadellie.unitto.data.KEY_2 import com.sadellie.unitto.data.KEY_2
@ -71,6 +66,8 @@ import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.getAndUpdate
import kotlinx.coroutines.flow.merge
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.isActive import kotlinx.coroutines.isActive
@ -84,77 +81,345 @@ import javax.inject.Inject
class MainViewModel @Inject constructor( class MainViewModel @Inject constructor(
private val userPrefsRepository: UserPreferencesRepository, private val userPrefsRepository: UserPreferencesRepository,
private val basedUnitRepository: MyBasedUnitsRepository, private val basedUnitRepository: MyBasedUnitsRepository,
private val mContext: Application,
private val allUnitsRepository: AllUnitsRepository private val allUnitsRepository: AllUnitsRepository
) : ViewModel() { ) : ViewModel() {
val input: MutableStateFlow<String> = MutableStateFlow(KEY_0)
private val _calculated: MutableStateFlow<String?> = MutableStateFlow(null)
private val _result: MutableStateFlow<String> = MutableStateFlow(KEY_0)
private val _latestInputStack: MutableList<String> = mutableListOf(KEY_0)
private val _inputDisplay: MutableStateFlow<String> = MutableStateFlow(KEY_0)
private val _isLoadingDatabase: MutableStateFlow<Boolean> = MutableStateFlow(true)
private val _isLoadingNetwork: MutableStateFlow<Boolean> = MutableStateFlow(false)
private val _showError: MutableStateFlow<Boolean> = MutableStateFlow(false)
private val _userPrefs = userPrefsRepository.userPreferencesFlow.stateIn( private val _userPrefs = userPrefsRepository.userPreferencesFlow.stateIn(
viewModelScope, SharingStarted.WhileSubscribed(5000), UserPreferences() viewModelScope,
SharingStarted.WhileSubscribed(5000),
UserPreferences()
) )
val mainFlow: StateFlow<MainScreenUIState> = combine( /**
_inputDisplay, * Unit on the left, the one we convert from. Initially null, meaning we didn't restore it yet.
*/
private val _unitFrom: MutableStateFlow<AbstractUnit?> = MutableStateFlow(null)
/**
* Unit on the right, the one we convert to. Initially null, meaning we didn't restore it yet.
*/
private val _unitTo: MutableStateFlow<AbstractUnit?> = MutableStateFlow(null)
/**
* Current input. Used when converting units.
*/
private val _input: MutableStateFlow<String> = MutableStateFlow(KEY_0)
/**
* Calculation result. Null when [_input] is not an expression.
*/
private val _calculated: MutableStateFlow<String?> = MutableStateFlow(null)
/**
* List of latest symbols that were entered.
*/
private val _latestInputStack: MutableList<String> = mutableListOf(_input.value)
/**
* Conversion result.
*/
private val _result: MutableStateFlow<String> = MutableStateFlow(KEY_0)
/**
* True when loading something from network.
*/
private val _showLoading: MutableStateFlow<Boolean> = MutableStateFlow(false)
/**
* True if there was error while loading data.
*/
private val _showError: MutableStateFlow<Boolean> = MutableStateFlow(false)
/**
* Current state of UI.
*/
val uiStateFlow: StateFlow<MainScreenUIState> = combine(
_input,
_unitFrom,
_unitTo,
_calculated, _calculated,
_result, _result,
_isLoadingNetwork, _showLoading,
_isLoadingDatabase, _showError
_showError, ) { inputValue, unitFromValue, unitToValue, calculatedValue, resultValue, showLoadingValue, showErrorValue ->
) { inputValue: String,
calculatedValue: String?,
resultValue: String,
showLoadingNetwork: Boolean,
showLoadingDatabase: Boolean,
showError: Boolean ->
return@combine MainScreenUIState( return@combine MainScreenUIState(
inputValue = inputValue, inputValue = inputValue,
calculatedValue = calculatedValue, calculatedValue = calculatedValue,
resultValue = resultValue, resultValue = resultValue,
isLoadingNetwork = showLoadingNetwork, showLoading = showLoadingValue,
isLoadingDatabase = showLoadingDatabase, showError = showErrorValue,
showError = showError, unitFrom = unitFromValue,
unitTo = unitToValue,
/**
* If there will be more modes, this should be a separate value which we update when
* changing units.
*/
mode = if (_unitFrom.value is NumberBaseUnit) ConverterMode.BASE else ConverterMode.DEFAULT
)
}
.stateIn(
viewModelScope,
SharingStarted.WhileSubscribed(5000),
MainScreenUIState()
) )
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), MainScreenUIState())
/** /**
* Unit we converting from (left side) * Process input with rules. Makes sure that user input is corrected when needed.
*
* @param symbolToAdd Use 'ugly' version of symbols.
*/ */
var unitFrom: AbstractUnit by mutableStateOf(allUnitsRepository.getById(MyUnitIDS.kilometer)) fun processInput(symbolToAdd: String) {
private set // We are still loading data from network, don't accept any input yet
if (_showLoading.value) return
val lastTwoSymbols = _latestInputStack.takeLast(2)
val lastSymbol: String = lastTwoSymbols.getOrNull(1) ?: lastTwoSymbols[0]
val lastSecondSymbol: String? = lastTwoSymbols.getOrNull(0)
when (symbolToAdd) {
KEY_PLUS, KEY_DIVIDE, KEY_MULTIPLY, KEY_EXPONENT -> {
when {
// Don't need expressions that start with zero
(_input.value == KEY_0) -> {}
(_input.value == KEY_MINUS) -> {}
(lastSymbol == KEY_LEFT_BRACKET) -> {}
(lastSymbol == KEY_SQRT) -> {}
/**
* For situations like "50+-", when user clicks "/" we delete "-" so it becomes
* "50+". We don't add "/' here. User will click "/" second time and the input
* will be "50/".
*/
(lastSecondSymbol in OPERATORS) and (lastSymbol == KEY_MINUS) -> {
deleteDigit()
}
// Don't allow multiple operators near each other
(lastSymbol in OPERATORS) -> {
deleteDigit()
setInputSymbols(symbolToAdd)
}
else -> {
setInputSymbols(symbolToAdd)
}
}
}
KEY_0 -> {
when {
// Don't add zero if the input is already a zero
(_input.value == KEY_0) -> {}
(lastSymbol == KEY_RIGHT_BRACKET) -> {
processInput(KEY_MULTIPLY)
setInputSymbols(symbolToAdd)
}
// Prevents things like "-00" and "4+000"
((lastSecondSymbol in OPERATORS + KEY_LEFT_BRACKET) and (lastSymbol == KEY_0)) -> {}
else -> {
setInputSymbols(symbolToAdd)
}
}
}
KEY_1, KEY_2, KEY_3, KEY_4, KEY_5, KEY_6, KEY_7, KEY_8, KEY_9 -> {
// Replace single zero (default input) if it's here
when {
(_input.value == KEY_0) -> {
setInputSymbols(symbolToAdd, false)
}
(lastSymbol == KEY_RIGHT_BRACKET) -> {
processInput(KEY_MULTIPLY)
setInputSymbols(symbolToAdd)
}
else -> {
setInputSymbols(symbolToAdd)
}
}
}
KEY_MINUS -> {
when {
// Replace single zero with minus (to support negative numbers)
(_input.value == KEY_0) -> {
setInputSymbols(symbolToAdd, false)
}
// Don't allow multiple minuses near each other
(lastSymbol.compareTo(KEY_MINUS) == 0) -> {}
// Don't allow plus and minus be near each other
(lastSymbol == KEY_PLUS) -> {
deleteDigit()
setInputSymbols(symbolToAdd)
}
else -> {
setInputSymbols(symbolToAdd)
}
}
}
KEY_DOT -> {
if (!_input.value
.takeLastWhile { it.toString() !in OPERATORS.minus(KEY_DOT) }
.contains(KEY_DOT)
) {
setInputSymbols(symbolToAdd)
}
}
KEY_LEFT_BRACKET -> {
when {
// Replace single zero with minus (to support negative numbers)
(_input.value == KEY_0) -> {
setInputSymbols(symbolToAdd, false)
}
(lastSymbol == KEY_RIGHT_BRACKET) || (lastSymbol in DIGITS) || (lastSymbol == KEY_DOT) -> {
processInput(KEY_MULTIPLY)
setInputSymbols(symbolToAdd)
}
else -> {
setInputSymbols(symbolToAdd)
}
}
}
KEY_RIGHT_BRACKET -> {
when {
// Replace single zero with minus (to support negative numbers)
(_input.value == KEY_0) -> {}
(lastSymbol == KEY_LEFT_BRACKET) -> {}
(
_latestInputStack.filter { it == KEY_LEFT_BRACKET }.size ==
_latestInputStack.filter { it == KEY_RIGHT_BRACKET }.size
) -> {
}
else -> {
setInputSymbols(symbolToAdd)
}
}
}
KEY_SQRT -> {
when {
// Replace single zero with minus (to support negative numbers)
(_input.value == KEY_0) -> {
setInputSymbols(symbolToAdd, false)
}
(lastSymbol == KEY_RIGHT_BRACKET) || (lastSymbol in DIGITS) || (lastSymbol == KEY_DOT) -> {
processInput(KEY_MULTIPLY)
setInputSymbols(symbolToAdd)
}
else -> {
setInputSymbols(symbolToAdd)
}
}
}
else -> {
when {
// Replace single zero with minus (to support negative numbers)
(_input.value == KEY_0) -> {
setInputSymbols(symbolToAdd, false)
}
else -> {
setInputSymbols(symbolToAdd)
}
}
}
}
}
/** /**
* Unit we are converting to (right side) * Update [_unitFrom] and set [_unitTo] from pair. Also updates stats for this [unit].
*/ */
var unitTo: AbstractUnit by mutableStateOf(allUnitsRepository.getById(MyUnitIDS.mile)) fun updateUnitFrom(unit: AbstractUnit) {
private set // We change from something to base converter or the other way around
if ((_unitFrom.value?.group != UnitGroup.NUMBER_BASE) xor (unit.group == UnitGroup.NUMBER_BASE)) {
_calculated.update { null }
clearInput()
}
_unitFrom.update { unit }
/**
* Update pair [_unitTo] if it exists
*/
val pair = unit.pairedUnit
if (pair != null) {
_unitTo.update { allUnitsRepository.getById(pair) }
} else {
// No pair, get something from same group
_unitTo.update { allUnitsRepository.getCollectionByGroup(unit.group).first() }
}
incrementCounter(unit)
updateCurrenciesRatesIfNeeded()
saveLatestPairOfUnits()
}
/**
* Update [_unitTo] and update pair for [_unitFrom].
*/
fun updateUnitTo(unit: AbstractUnit) {
_unitTo.update { unit }
_unitFrom.value?.pairedUnit = unit.unitId
updatePairedUnit(_unitFrom.value ?: return)
incrementCounter(unit)
saveLatestPairOfUnits()
}
/**
* Swap [_unitFrom] and [_unitTo].
*/
fun swapUnits() {
_unitFrom
.getAndUpdate { _unitTo.value }
.also { oldUnitFrom -> _unitTo.update { oldUnitFrom } }
}
/**
* Delete last symbol from [_input].
*/
fun deleteDigit() {
// Default input, don't delete
if (_input.value == KEY_0) return
val lastSymbol = _latestInputStack.removeLast()
// If this value are same, it means that after deleting there will be no symbols left, set to default
if (lastSymbol == _input.value) {
setInputSymbols(KEY_0, false)
} else {
_input.update { it.removeSuffix(lastSymbol) }
}
}
/**
* Clear [_input].
*/
fun clearInput() {
setInputSymbols(KEY_0, false)
}
fun getInputValue(): String {
return _calculated.value ?: _input.value
}
private suspend fun convertInput() {
if (_unitFrom.value?.group == UnitGroup.NUMBER_BASE) {
convertAsNumberBase()
} else {
convertAsExpression()
}
}
private suspend fun convertAsNumberBase() { private suspend fun convertAsNumberBase() {
withContext(Dispatchers.Default) { withContext(Dispatchers.Default) {
while (isActive) { while (isActive) {
// Units are still loading, don't convert anything yet
val unitFrom = _unitFrom.value ?: return@withContext
val unitTo = _unitTo.value ?: return@withContext
val conversionResult = try { val conversionResult = try {
(unitFrom as NumberBaseUnit).convertToBase( (unitFrom as NumberBaseUnit).convertToBase(
input = input.value, input = _input.value,
toBase = (unitTo as NumberBaseUnit).base, toBase = (unitTo as NumberBaseUnit).base
) )
} catch (e: Exception) { } catch (e: Exception) {
when (e) { when (e) {
is NumberFormatException, is IllegalArgumentException -> {
""
}
is ClassCastException -> { is ClassCastException -> {
cancel() cancel()
return@withContext return@withContext
} }
else -> { is NumberFormatException, is IllegalArgumentException -> ""
throw e else -> throw e
}
} }
} }
_result.update { conversionResult } _result.update { conversionResult }
@ -166,13 +431,17 @@ class MainViewModel @Inject constructor(
private suspend fun convertAsExpression() { private suspend fun convertAsExpression() {
withContext(Dispatchers.Default) { withContext(Dispatchers.Default) {
while (isActive) { while (isActive) {
// Units are still loading, don't convert anything yet
val unitFrom = _unitFrom.value ?: return@withContext
val unitTo = _unitTo.value ?: return@withContext
// First we clean the input from garbage at the end // First we clean the input from garbage at the end
var cleanInput = input.value.dropLastWhile { !it.isDigit() } var cleanInput = _input.value.dropLastWhile { !it.isDigit() }
// Now we close open brackets that user didn't close // Now we close open brackets that user didn't close
// AUTOCLOSE ALL BRACKETS // AUTOCLOSE ALL BRACKETS
val leftBrackets = input.value.count { it.toString() == KEY_LEFT_BRACKET } val leftBrackets = _input.value.count { it.toString() == KEY_LEFT_BRACKET }
val rightBrackets = input.value.count { it.toString() == KEY_RIGHT_BRACKET } val rightBrackets = _input.value.count { it.toString() == KEY_RIGHT_BRACKET }
val neededBrackets = leftBrackets - rightBrackets val neededBrackets = leftBrackets - rightBrackets
if (neededBrackets > 0) cleanInput += KEY_RIGHT_BRACKET.repeat(neededBrackets) if (neededBrackets > 0) cleanInput += KEY_RIGHT_BRACKET.repeat(neededBrackets)
@ -200,17 +469,23 @@ class MainViewModel @Inject constructor(
// 123.456 will be true // 123.456 will be true
// -123.456 will be true // -123.456 will be true
// -123.456-123 will be false (first minus gets removed, ending with 123.456) // -123.456-123 will be false (first minus gets removed, ending with 123.456)
if (input.value.removePrefix(KEY_MINUS).all { it.toString() !in OPERATORS }) { if (_input.value.removePrefix(KEY_MINUS).all { it.toString() !in OPERATORS }) {
// No operators // No operators
_calculated.update { null } _calculated.update { null }
} else { } else {
_calculated.update { evaluationResult.toStringWith(_userPrefs.value.outputFormat) } _calculated.update {
evaluationResult.toStringWith(
_userPrefs.value.outputFormat
)
}
} }
// Now we just convert. // Now we just convert.
// We can use evaluation result here, input is valid // We can use evaluation result here, input is valid
val conversionResult: BigDecimal = unitFrom.convert( val conversionResult: BigDecimal = unitFrom.convert(
unitTo, evaluationResult, _userPrefs.value.digitsPrecision unitTo,
evaluationResult,
_userPrefs.value.digitsPrecision
) )
// Converted // Converted
@ -221,110 +496,53 @@ class MainViewModel @Inject constructor(
} }
} }
/** private fun setInputSymbols(symbol: String, add: Boolean = true) {
* This function takes local variables, converts values and then causes the UI to update if (add) {
*/ _input.update { it + symbol }
private suspend fun convertInput() {
if (unitFrom.group == UnitGroup.NUMBER_BASE) {
convertAsNumberBase()
} else { } else {
convertAsExpression() // We don't need previous input, clear entirely
_latestInputStack.clear()
_input.update { symbol }
} }
_latestInputStack.add(symbol)
} }
/** private fun incrementCounter(unit: AbstractUnit) {
* Change left side unit. Unit to convert from viewModelScope.launch(Dispatchers.IO) {
*
* @param clickedUnit Unit we need to change to
*/
fun changeUnitFrom(clickedUnit: AbstractUnit) {
// Do we change to NumberBase?
if ((unitFrom.group != UnitGroup.NUMBER_BASE) and (clickedUnit.group == UnitGroup.NUMBER_BASE)) {
// It was not NUMBER_BASE, but now we change to it. Clear input.
clearInput()
}
if ((unitFrom.group == UnitGroup.NUMBER_BASE) and (clickedUnit.group != UnitGroup.NUMBER_BASE)) {
// It was NUMBER_BASE, but now we change to something else. Clear input.
clearInput()
}
// First we change unit
unitFrom = clickedUnit
// Now we change to positive if the group we switched to supports negate
if (!clickedUnit.group.canNegate) {
input.update { input.value.removePrefix(KEY_MINUS) }
}
// Now setting up right unit (pair for the left one)
unitTo = if (unitFrom.pairedUnit == null) {
allUnitsRepository.getCollectionByGroup(unitFrom.group).first()
} else {
allUnitsRepository.getById(unitFrom.pairedUnit!!)
}
viewModelScope.launch {
// We need to increment counter for the clicked unit
incrementCounter(clickedUnit)
// Currencies require us to get data from the internet
updateCurrenciesBasicUnits()
// Saving latest pair
saveLatestPairOfUnits()
}
}
/**
* Change right side unit. Unit to convert to
*
* @param clickedUnit Unit we need to change to
*/
fun changeUnitTo(clickedUnit: AbstractUnit) {
// First we change unit
unitTo = clickedUnit
// Updating paired unit for left side unit in memory (same thing for database below)
unitFrom.pairedUnit = unitTo.unitId
viewModelScope.launch {
// Updating paired unit for left side unit in database
basedUnitRepository.insertUnits( basedUnitRepository.insertUnits(
MyBasedUnit( MyBasedUnit(
unitId = unitFrom.unitId, unitId = unit.unitId,
isFavorite = unitFrom.isFavorite, isFavorite = unit.isFavorite,
pairedUnitId = unitFrom.pairedUnit, pairedUnitId = unit.pairedUnit,
frequency = unitFrom.counter
)
)
// We also need to increment counter for the selected unit
incrementCounter(clickedUnit)
// Saving latest pair
saveLatestPairOfUnits()
}
}
private suspend fun incrementCounter(unit: AbstractUnit) {
basedUnitRepository.insertUnits(
MyBasedUnit(
unitId = unit.unitId, isFavorite = unit.isFavorite, pairedUnitId = unit.pairedUnit,
// This will increment counter on unit in list too // This will increment counter on unit in list too
frequency = ++unit.counter frequency = ++unit.counter
) )
) )
} }
}
/** private fun updatePairedUnit(unit: AbstractUnit) {
* Updates basic units properties for all currencies, BUT only when [unitFrom]'s group is set viewModelScope.launch(Dispatchers.IO) {
* to [UnitGroup.CURRENCY]. basedUnitRepository.insertUnits(
*/ MyBasedUnit(
private suspend fun updateCurrenciesBasicUnits() { unitId = unit.unitId,
// Resetting error and network loading states in case we are not gonna do anything below isFavorite = unit.isFavorite,
_isLoadingNetwork.update { false } pairedUnitId = unit.pairedUnit,
frequency = unit.counter
)
)
}
}
private fun updateCurrenciesRatesIfNeeded() {
viewModelScope.launch(Dispatchers.IO) {
_showError.update { false } _showError.update { false }
// We update currencies only when needed _showLoading.update { false }
if (unitFrom.group != UnitGroup.CURRENCY) return // Units are still loading, don't convert anything yet
val unitFrom = _unitFrom.value ?: return@launch
if (_unitFrom.value?.group != UnitGroup.CURRENCY) return@launch
// Starting to load stuff // Starting to load stuff
_isLoadingNetwork.update { true } _showLoading.update { true }
try { try {
val pairs: CurrencyUnitResponse = val pairs: CurrencyUnitResponse =
@ -341,278 +559,53 @@ class MainViewModel @Inject constructor(
} }
_showError.update { true } _showError.update { true }
} finally { } finally {
// Loaded _showLoading.update { false }
_isLoadingNetwork.update { false }
}
}
/**
* Swaps measurement, left to right and vice versa
*/
fun swapUnits() {
unitFrom = unitTo.also {
unitTo = unitFrom
}
viewModelScope.launch {
updateCurrenciesBasicUnits()
saveLatestPairOfUnits()
}
}
/**
* Function to process input when we click keyboard. Make sure that digits/symbols will be
* added properly
* @param[symbolToAdd] Digit/Symbol we want to add, can be any digit 0..9 or a dot symbol
*/
fun processInput(symbolToAdd: String) {
val lastTwoSymbols = _latestInputStack.takeLast(2)
val lastSymbol: String = lastTwoSymbols.getOrNull(1) ?: lastTwoSymbols[0]
val lastSecondSymbol: String? = lastTwoSymbols.getOrNull(0)
when (symbolToAdd) {
KEY_PLUS, KEY_DIVIDE, KEY_MULTIPLY, KEY_EXPONENT -> {
when {
// Don't need expressions that start with zero
(input.value == KEY_0) -> {}
(input.value == KEY_MINUS) -> {}
(lastSymbol == KEY_LEFT_BRACKET) -> {}
(lastSymbol == KEY_SQRT) -> {}
/**
* For situations like "50+-", when user clicks "/" we delete "-" so it becomes
* "50+". We don't add "/' here. User will click "/" second time and the input
* will be "50/".
*/
(lastSecondSymbol in OPERATORS) and (lastSymbol == KEY_MINUS) -> {
deleteDigit()
}
// Don't allow multiple operators near each other
(lastSymbol in OPERATORS) -> {
deleteDigit()
setInputSymbols(symbolToAdd)
}
else -> {
setInputSymbols(symbolToAdd)
}
}
}
KEY_0 -> {
when {
// Don't add zero if the input is already a zero
(input.value == KEY_0) -> {}
(lastSymbol == KEY_RIGHT_BRACKET) -> {
processInput(KEY_MULTIPLY)
setInputSymbols(symbolToAdd)
}
// Prevents things like "-00" and "4+000"
((lastSecondSymbol in OPERATORS + KEY_LEFT_BRACKET) and (lastSymbol == KEY_0)) -> {}
else -> {
setInputSymbols(symbolToAdd)
}
}
}
KEY_1, KEY_2, KEY_3, KEY_4, KEY_5, KEY_6, KEY_7, KEY_8, KEY_9 -> {
// Replace single zero (default input) if it's here
when {
(input.value == KEY_0) -> {
setInputSymbols(symbolToAdd, false)
}
(lastSymbol == KEY_RIGHT_BRACKET) -> {
processInput(KEY_MULTIPLY)
setInputSymbols(symbolToAdd)
}
else -> {
setInputSymbols(symbolToAdd)
}
}
}
KEY_MINUS -> {
when {
// Replace single zero with minus (to support negative numbers)
(input.value == KEY_0) -> {
setInputSymbols(symbolToAdd, false)
}
// Don't allow multiple minuses near each other
(lastSymbol.compareTo(KEY_MINUS) == 0) -> {}
// Don't allow plus and minus be near each other
(lastSymbol == KEY_PLUS) -> {
deleteDigit()
setInputSymbols(symbolToAdd)
}
else -> {
setInputSymbols(symbolToAdd)
}
}
}
KEY_DOT -> {
if (canEnterDot()) {
setInputSymbols(symbolToAdd)
}
}
KEY_LEFT_BRACKET -> {
when {
// Replace single zero with minus (to support negative numbers)
(input.value == KEY_0) -> {
setInputSymbols(symbolToAdd, false)
}
(lastSymbol == KEY_RIGHT_BRACKET) || (lastSymbol in DIGITS) || (lastSymbol == KEY_DOT) -> {
processInput(KEY_MULTIPLY)
setInputSymbols(symbolToAdd)
}
else -> {
setInputSymbols(symbolToAdd)
}
}
}
KEY_RIGHT_BRACKET -> {
when {
// Replace single zero with minus (to support negative numbers)
(input.value == KEY_0) -> {}
(lastSymbol == KEY_LEFT_BRACKET) -> {}
(_latestInputStack.filter { it == KEY_LEFT_BRACKET }.size ==
_latestInputStack.filter { it == KEY_RIGHT_BRACKET }.size) -> {
}
else -> {
setInputSymbols(symbolToAdd)
}
}
}
KEY_SQRT -> {
when {
// Replace single zero with minus (to support negative numbers)
(input.value == KEY_0) -> {
setInputSymbols(symbolToAdd, false)
}
(lastSymbol == KEY_RIGHT_BRACKET) || (lastSymbol in DIGITS) || (lastSymbol == KEY_DOT) -> {
processInput(KEY_MULTIPLY)
setInputSymbols(symbolToAdd)
}
else -> {
setInputSymbols(symbolToAdd)
}
}
}
else -> {
when {
// Replace single zero with minus (to support negative numbers)
(input.value == KEY_0) -> {
setInputSymbols(symbolToAdd, false)
}
else -> {
setInputSymbols(symbolToAdd)
}
}
}
}
}
/**
* Deletes last symbol from input and handles buttons state (enabled/disabled)
*/
fun deleteDigit() {
// Default input, don't delete
if (input.value == KEY_0) return
val lastSymbol = _latestInputStack.removeLast()
// We will need to delete last symbol from both values
val displayRepresentation: String = INTERNAL_DISPLAY[lastSymbol] ?: lastSymbol
// If this value are same, it means that after deleting there will be no symbols left, set to default
if (lastSymbol == input.value) {
setInputSymbols(KEY_0, false)
} else {
input.update { it.removeSuffix(lastSymbol) }
_inputDisplay.update { it.removeSuffix(displayRepresentation) }
}
}
/**
* Adds given [symbol] to [input] and [_inputDisplay] and updates [_latestInputStack].
*
* By default add symbol, but if [add] is False, will replace current input (when replacing
* default [KEY_0] input).
*/
private fun setInputSymbols(symbol: String, add: Boolean = true) {
val displaySymbol: String = INTERNAL_DISPLAY[symbol] ?: symbol
when {
add -> {
input.update { it + symbol }
_inputDisplay.update { it + displaySymbol }
_latestInputStack.add(symbol)
}
else -> {
_latestInputStack.clear()
input.update { symbol }
_inputDisplay.update { displaySymbol }
_latestInputStack.add(symbol)
} }
} }
} }
/** private fun saveLatestPairOfUnits() {
* Clears input value and sets it to default (ZERO) viewModelScope.launch(Dispatchers.IO) {
*/ // Units are still loading, don't convert anything yet
fun clearInput() { val unitFrom = _unitFrom.value ?: return@launch
setInputSymbols(KEY_0, false) val unitTo = _unitTo.value ?: return@launch
}
/**
* Returns value to be used when converting value on the right side screen (unit selection)
*/
fun inputValue(): String {
return mainFlow.value.calculatedValue ?: mainFlow.value.inputValue
}
/**
* Returns True if can be placed.
*/
private fun canEnterDot(): Boolean = !input.value.takeLastWhile {
it.toString() !in OPERATORS.minus(KEY_DOT)
}.contains(KEY_DOT)
/**
* Saves latest pair of units into datastore
*/
private suspend fun saveLatestPairOfUnits() {
userPrefsRepository.updateLatestPairOfUnits(unitFrom, unitTo) userPrefsRepository.updateLatestPairOfUnits(unitFrom, unitTo)
} }
}
private fun startObserving() { private fun startObserving() {
viewModelScope.launch(Dispatchers.Default) { viewModelScope.launch(Dispatchers.Default) {
_userPrefs.collectLatest { convertInput() } merge(_input, _unitFrom, _unitTo, _userPrefs).collectLatest { convertInput() }
}
viewModelScope.launch(Dispatchers.Default) {
input.collectLatest { convertInput() }
} }
} }
init { private fun loadInitialUnitPair() {
viewModelScope.launch { viewModelScope.launch(Dispatchers.IO) {
val initialUserPrefs = userPrefsRepository.userPreferencesFlow.first() val initialUserPrefs = userPrefsRepository.userPreferencesFlow.first()
// First we load latest pair of units // First we load latest pair of units
unitFrom = try { _unitFrom.update {
try {
allUnitsRepository.getById(initialUserPrefs.latestLeftSideUnit) allUnitsRepository.getById(initialUserPrefs.latestLeftSideUnit)
} catch (e: java.util.NoSuchElementException) { } catch (e: java.util.NoSuchElementException) {
allUnitsRepository.getById(MyUnitIDS.kilometer) allUnitsRepository.getById(MyUnitIDS.kilometer)
} }
}
unitTo = try { _unitTo.update {
try {
allUnitsRepository.getById(initialUserPrefs.latestRightSideUnit) allUnitsRepository.getById(initialUserPrefs.latestRightSideUnit)
} catch (e: java.util.NoSuchElementException) { } catch (e: java.util.NoSuchElementException) {
allUnitsRepository.getById(MyUnitIDS.mile) allUnitsRepository.getById(MyUnitIDS.mile)
} }
}
// Now we load units data from database updateCurrenciesRatesIfNeeded()
val allBasedUnits = basedUnitRepository.getAll() }
allUnitsRepository.loadFromDatabase(mContext, allBasedUnits) }
// User is free to convert values and units on units screen can be sorted properly
_isLoadingDatabase.update { false }
updateCurrenciesBasicUnits()
init {
loadInitialUnitPair()
startObserving() startObserving()
} }
}
} }

View File

@ -18,6 +18,7 @@
package com.sadellie.unitto.screens.main.components package com.sadellie.unitto.screens.main.components
import androidx.compose.animation.Crossfade
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
@ -55,6 +56,7 @@ import com.sadellie.unitto.data.KEY_PLUS
import com.sadellie.unitto.data.KEY_RIGHT_BRACKET import com.sadellie.unitto.data.KEY_RIGHT_BRACKET
import com.sadellie.unitto.data.KEY_SQRT import com.sadellie.unitto.data.KEY_SQRT
import com.sadellie.unitto.screens.Formatter import com.sadellie.unitto.screens.Formatter
import com.sadellie.unitto.screens.main.ConverterMode
/** /**
* Keyboard with button that looks like a calculator * Keyboard with button that looks like a calculator
@ -63,7 +65,7 @@ import com.sadellie.unitto.screens.Formatter
* @param addDigit Function that is called when clicking number and dot buttons * @param addDigit Function that is called when clicking number and dot buttons
* @param deleteDigit Function that is called when clicking delete "<" button * @param deleteDigit Function that is called when clicking delete "<" button
* @param clearInput Function that is called when clicking clear "AC" button * @param clearInput Function that is called when clicking clear "AC" button
* @param baseConverter When True will use layout for base conversion. * @param converterMode
*/ */
@Composable @Composable
fun Keyboard( fun Keyboard(
@ -71,7 +73,23 @@ fun Keyboard(
addDigit: (String) -> Unit = {}, addDigit: (String) -> Unit = {},
deleteDigit: () -> Unit = {}, deleteDigit: () -> Unit = {},
clearInput: () -> Unit = {}, clearInput: () -> Unit = {},
baseConverter: Boolean = false, converterMode: ConverterMode,
) {
Crossfade(converterMode) {
when (it) {
ConverterMode.DEFAULT -> DefaultKeyboard(modifier, addDigit, clearInput, deleteDigit)
ConverterMode.BASE -> BaseKeyboard(modifier, addDigit, clearInput, deleteDigit)
}
}
}
@Composable
private fun DefaultKeyboard(
modifier: Modifier,
addDigit: (String) -> Unit,
clearInput: () -> Unit,
deleteDigit: () -> Unit
) { ) {
Column( Column(
modifier = modifier.fillMaxSize() modifier = modifier.fillMaxSize()
@ -83,37 +101,6 @@ fun Keyboard(
.padding(4.dp) .padding(4.dp)
// Column modifier // Column modifier
val cModifier = Modifier.weight(1f) val cModifier = Modifier.weight(1f)
if (baseConverter) {
Row(cModifier) {
KeyboardButton(bModifier, KEY_BASE_A, isPrimary = false, onClick = addDigit)
KeyboardButton(bModifier, KEY_BASE_B, isPrimary = false, onClick = addDigit)
KeyboardButton(bModifier, KEY_BASE_C, isPrimary = false, onClick = addDigit)
}
Row(cModifier) {
KeyboardButton(bModifier, KEY_BASE_D, isPrimary = false, onClick = addDigit)
KeyboardButton(bModifier, KEY_BASE_E, isPrimary = false, onClick = addDigit)
KeyboardButton(bModifier, KEY_BASE_F, isPrimary = false, onClick = addDigit)
}
Row(cModifier) {
KeyboardButton(bModifier, KEY_7, onClick = addDigit)
KeyboardButton(bModifier, KEY_8, onClick = addDigit)
KeyboardButton(bModifier, KEY_9, onClick = addDigit)
}
Row(cModifier) {
KeyboardButton(bModifier, KEY_4, onClick = addDigit)
KeyboardButton(bModifier, KEY_5, onClick = addDigit)
KeyboardButton(bModifier, KEY_6, onClick = addDigit)
}
Row(cModifier) {
KeyboardButton(bModifier, KEY_1, onClick = addDigit)
KeyboardButton(bModifier, KEY_2, onClick = addDigit)
KeyboardButton(bModifier, KEY_3, onClick = addDigit)
}
Row(cModifier) {
KeyboardButton(bModifier, KEY_0, onClick = addDigit)
KeyboardButton(Modifier.fillMaxSize().weight(2f).padding(4.dp), KEY_CLEAR, onLongClick = clearInput) { deleteDigit() }
}
} else {
Row(cModifier) { Row(cModifier) {
KeyboardButton(bModifier, KEY_LEFT_BRACKET, isPrimary = false, onClick = addDigit) KeyboardButton(bModifier, KEY_LEFT_BRACKET, isPrimary = false, onClick = addDigit)
KeyboardButton(bModifier, KEY_RIGHT_BRACKET, isPrimary = false, onClick = addDigit) KeyboardButton(bModifier, KEY_RIGHT_BRACKET, isPrimary = false, onClick = addDigit)
@ -145,5 +132,60 @@ fun Keyboard(
KeyboardButton(bModifier, KEY_PLUS, isPrimary = false) { addDigit(KEY_PLUS) } KeyboardButton(bModifier, KEY_PLUS, isPrimary = false) { addDigit(KEY_PLUS) }
} }
} }
}
@Composable
private fun BaseKeyboard(
modifier: Modifier,
addDigit: (String) -> Unit,
clearInput: () -> Unit,
deleteDigit: () -> Unit
) {
Column(
modifier = modifier.fillMaxSize()
) {
// Button modifier
val bModifier = Modifier
.fillMaxSize()
.weight(1f)
.padding(4.dp)
// Column modifier
val cModifier = Modifier.weight(1f)
Row(cModifier) {
KeyboardButton(bModifier, KEY_BASE_A, isPrimary = false, onClick = addDigit)
KeyboardButton(bModifier, KEY_BASE_B, isPrimary = false, onClick = addDigit)
KeyboardButton(bModifier, KEY_BASE_C, isPrimary = false, onClick = addDigit)
}
Row(cModifier) {
KeyboardButton(bModifier, KEY_BASE_D, isPrimary = false, onClick = addDigit)
KeyboardButton(bModifier, KEY_BASE_E, isPrimary = false, onClick = addDigit)
KeyboardButton(bModifier, KEY_BASE_F, isPrimary = false, onClick = addDigit)
}
Row(cModifier) {
KeyboardButton(bModifier, KEY_7, onClick = addDigit)
KeyboardButton(bModifier, KEY_8, onClick = addDigit)
KeyboardButton(bModifier, KEY_9, onClick = addDigit)
}
Row(cModifier) {
KeyboardButton(bModifier, KEY_4, onClick = addDigit)
KeyboardButton(bModifier, KEY_5, onClick = addDigit)
KeyboardButton(bModifier, KEY_6, onClick = addDigit)
}
Row(cModifier) {
KeyboardButton(bModifier, KEY_1, onClick = addDigit)
KeyboardButton(bModifier, KEY_2, onClick = addDigit)
KeyboardButton(bModifier, KEY_3, onClick = addDigit)
}
Row(cModifier) {
KeyboardButton(bModifier, KEY_0, onClick = addDigit)
KeyboardButton(
Modifier
.fillMaxSize()
.weight(2f)
.padding(4.dp),
KEY_CLEAR,
onLongClick = clearInput
) { deleteDigit() }
}
} }
} }

View File

@ -45,24 +45,24 @@ import com.sadellie.unitto.data.NavRoutes.LEFT_LIST_SCREEN
import com.sadellie.unitto.data.NavRoutes.RIGHT_LIST_SCREEN import com.sadellie.unitto.data.NavRoutes.RIGHT_LIST_SCREEN
import com.sadellie.unitto.data.units.AbstractUnit import com.sadellie.unitto.data.units.AbstractUnit
import com.sadellie.unitto.screens.Formatter import com.sadellie.unitto.screens.Formatter
import com.sadellie.unitto.screens.main.ConverterMode
/** /**
* Top of the main screen. Contains input and output TextFields, and unit selection row of buttons. * Top of the main screen. Contains input and output TextFields, and unit selection row of buttons.
* It's a separate composable, so that we support album orientation (this element will be on the left) * It's a separate composable, so that we support album orientation (this element will be on the left)
* *
* @param modifier Modifier that is applied to Column * @param modifier Modifier that is applied to Column.
* @param inputValue Current input value (like big decimal) * @param inputValue Current input value (like big decimal).
* @param calculatedValue Current calculated value (like big decimal), will be shown under input when it * @param calculatedValue Current calculated value (like big decimal), will be shown under input when it
* has an expression in it. * has an expression in it.
* @param outputValue Current output value (like big decimal) * @param outputValue Current output value (like big decimal).
* @param unitFrom [AbstractUnit] on the left * @param unitFrom [AbstractUnit] on the left.
* @param unitTo [AbstractUnit] on the right * @param unitTo [AbstractUnit] on the right.
* @param loadingDatabase Are we still loading units usage stats from database? Disables unit * @param loadingNetwork Are we loading data from network? Shows loading text in TextFields.
* selection buttons. * @param networkError Did we got errors while trying to get data from network.
* @param loadingNetwork Are we loading data from network? Shows loading text in TextFields * @param onUnitSelectionClick Function that is called when clicking unit selection buttons.
* @param networkError Did we got errors while trying to get data from network * @param swapUnits Method to swap units.
* @param onUnitSelectionClick Function that is called when clicking unit selection buttons * @param converterMode [ConverterMode.BASE] doesn't use formatting for input/output.
* @param swapUnits Method to swap units
*/ */
@Composable @Composable
fun TopScreenPart( fun TopScreenPart(
@ -70,14 +70,13 @@ fun TopScreenPart(
inputValue: String, inputValue: String,
calculatedValue: String?, calculatedValue: String?,
outputValue: String, outputValue: String,
unitFrom: AbstractUnit, unitFrom: AbstractUnit?,
unitTo: AbstractUnit, unitTo: AbstractUnit?,
loadingDatabase: Boolean,
loadingNetwork: Boolean, loadingNetwork: Boolean,
networkError: Boolean, networkError: Boolean,
onUnitSelectionClick: (String) -> Unit, onUnitSelectionClick: (String) -> Unit,
swapUnits: () -> Unit, swapUnits: () -> Unit,
baseConverterMode: Boolean, converterMode: ConverterMode,
) { ) {
var swapped by remember { mutableStateOf(false) } var swapped by remember { mutableStateOf(false) }
val swapButtonRotation: Float by animateFloatAsState( val swapButtonRotation: Float by animateFloatAsState(
@ -93,28 +92,28 @@ fun TopScreenPart(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
primaryText = { primaryText = {
when { when {
loadingDatabase || loadingNetwork -> stringResource(R.string.loading_label) loadingNetwork -> stringResource(R.string.loading_label)
networkError -> stringResource(R.string.error_label) networkError -> stringResource(R.string.error_label)
baseConverterMode -> inputValue.uppercase() converterMode == ConverterMode.BASE -> inputValue.uppercase()
else -> Formatter.format(inputValue) else -> Formatter.format(inputValue)
} }
}, },
secondaryText = calculatedValue?.let { Formatter.format(it) }, secondaryText = calculatedValue?.let { Formatter.format(it) },
helperText = stringResource(if (loadingDatabase) R.string.loading_label else unitFrom.shortName), helperText = stringResource(unitFrom?.shortName ?: R.string.loading_label),
textToCopy = calculatedValue ?: inputValue, textToCopy = calculatedValue ?: inputValue,
) )
MyTextField( MyTextField(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
primaryText = { primaryText = {
when { when {
loadingDatabase || loadingNetwork -> stringResource(R.string.loading_label) loadingNetwork -> stringResource(R.string.loading_label)
networkError -> stringResource(R.string.error_label) networkError -> stringResource(R.string.error_label)
baseConverterMode -> outputValue.uppercase() converterMode == ConverterMode.BASE -> outputValue.uppercase()
else -> Formatter.format(outputValue) else -> Formatter.format(outputValue)
} }
}, },
secondaryText = null, secondaryText = null,
helperText = stringResource(if (loadingDatabase) R.string.loading_label else unitTo.shortName), helperText = stringResource(unitTo?.shortName ?: R.string.loading_label),
textToCopy = outputValue, textToCopy = outputValue,
) )
// Unit selection buttons // Unit selection buttons
@ -127,15 +126,14 @@ fun TopScreenPart(
.fillMaxWidth() .fillMaxWidth()
.weight(1f), .weight(1f),
onClick = { onUnitSelectionClick(LEFT_LIST_SCREEN) }, onClick = { onUnitSelectionClick(LEFT_LIST_SCREEN) },
label = unitFrom.displayName, label = unitFrom?.displayName ?: R.string.loading_label,
isLoading = loadingDatabase
) )
IconButton( IconButton(
onClick = { onClick = {
swapUnits() swapUnits()
swapped = !swapped swapped = !swapped
}, },
enabled = !loadingDatabase enabled = unitFrom != null
) { ) {
Icon( Icon(
modifier = Modifier.rotate(swapButtonRotation), modifier = Modifier.rotate(swapButtonRotation),
@ -148,8 +146,7 @@ fun TopScreenPart(
.fillMaxWidth() .fillMaxWidth()
.weight(1f), .weight(1f),
onClick = { onUnitSelectionClick(RIGHT_LIST_SCREEN) }, onClick = { onUnitSelectionClick(RIGHT_LIST_SCREEN) },
label = unitTo.displayName, label = unitTo?.displayName ?: R.string.loading_label,
isLoading = loadingDatabase
) )
} }
} }

View File

@ -43,26 +43,24 @@ import com.sadellie.unitto.R
* @param modifier Modifier that is applied to a [Button] * @param modifier Modifier that is applied to a [Button]
* @param onClick Function to call when button is clicked (navigate to a unit selection screen) * @param onClick Function to call when button is clicked (navigate to a unit selection screen)
* @param label Text on button * @param label Text on button
* @param isLoading Show "Loading" text and disable button
*/ */
@Composable @Composable
fun UnitSelectionButton( fun UnitSelectionButton(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
onClick: () -> Unit = {}, onClick: () -> Unit = {},
label: Int, label: Int?,
isLoading: Boolean
) { ) {
Button( Button(
modifier = modifier, modifier = modifier,
onClick = { onClick() }, onClick = { onClick() },
enabled = !isLoading, enabled = label != null,
colors = ButtonDefaults.buttonColors( colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.primaryContainer containerColor = MaterialTheme.colorScheme.primaryContainer
), ),
contentPadding = PaddingValues(vertical = 16.dp, horizontal = 8.dp) contentPadding = PaddingValues(vertical = 16.dp, horizontal = 8.dp)
) { ) {
AnimatedContent( AnimatedContent(
targetState = label, targetState = label ?: 0,
transitionSpec = { transitionSpec = {
if (targetState > initialState) { if (targetState > initialState) {
slideInVertically { height -> height } + fadeIn() with slideInVertically { height -> height } + fadeIn() with
@ -76,7 +74,7 @@ fun UnitSelectionButton(
} }
) { ) {
Text( Text(
text = stringResource(if (isLoading) R.string.loading_label else label), text = stringResource(label ?: R.string.loading_label),
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
color = MaterialTheme.colorScheme.onSecondaryContainer color = MaterialTheme.colorScheme.onSecondaryContainer

View File

@ -18,6 +18,7 @@
package com.sadellie.unitto.screens.second package com.sadellie.unitto.screens.second
import android.app.Application
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.sadellie.unitto.data.units.AbstractUnit import com.sadellie.unitto.data.units.AbstractUnit
@ -41,7 +42,8 @@ import javax.inject.Inject
class SecondViewModel @Inject constructor( class SecondViewModel @Inject constructor(
private val basedUnitRepository: MyBasedUnitsRepository, private val basedUnitRepository: MyBasedUnitsRepository,
private val allUnitsRepository: AllUnitsRepository, private val allUnitsRepository: AllUnitsRepository,
unitGroupsRepository: UnitGroupsRepository private val mContext: Application,
unitGroupsRepository: UnitGroupsRepository,
) : ViewModel() { ) : ViewModel() {
private val _favoritesOnly = MutableStateFlow(false) private val _favoritesOnly = MutableStateFlow(false)
@ -145,4 +147,16 @@ class SecondViewModel @Inject constructor(
) )
} }
} }
private fun loadBasedUnits() {
viewModelScope.launch(Dispatchers.IO) {
// Now we load units data from database
val allBasedUnits = basedUnitRepository.getAll()
allUnitsRepository.loadFromDatabase(mContext, allBasedUnits)
}
}
init {
loadBasedUnits()
}
} }

View File

@ -45,7 +45,7 @@ class FormatterTest {
assertEquals("123 456.", formatter.format(INCOMPLETE_VALUE)) assertEquals("123 456.", formatter.format(INCOMPLETE_VALUE))
assertEquals("123 456", formatter.format(NO_FRACTIONAL_VALUE)) assertEquals("123 456", formatter.format(NO_FRACTIONAL_VALUE))
assertEquals("50+123 456÷8×0.812+", formatter.format(INCOMPLETE_EXPR)) assertEquals("50+123 456÷8×0.812+", formatter.format(INCOMPLETE_EXPR))
assertEquals("50+123 456÷8×0.812+0-√9*4^9+2×(9+8×7)", formatter.format(COMPLETE_EXPR)) assertEquals("50+123 456÷8×0.812+0√9×4^9+2×(9+8×7)", formatter.format(COMPLETE_EXPR))
assertEquals("((((((((", formatter.format(SOME_BRACKETS)) assertEquals("((((((((", formatter.format(SOME_BRACKETS))
} }
@ -59,7 +59,7 @@ class FormatterTest {
assertEquals("123,456.", formatter.format(INCOMPLETE_VALUE)) assertEquals("123,456.", formatter.format(INCOMPLETE_VALUE))
assertEquals("123,456", formatter.format(NO_FRACTIONAL_VALUE)) assertEquals("123,456", formatter.format(NO_FRACTIONAL_VALUE))
assertEquals("50+123,456÷8×0.812+", formatter.format(INCOMPLETE_EXPR)) assertEquals("50+123,456÷8×0.812+", formatter.format(INCOMPLETE_EXPR))
assertEquals("50+123,456÷8×0.812+0-√9*4^9+2×(9+8×7)", formatter.format(COMPLETE_EXPR)) assertEquals("50+123,456÷8×0.812+0√9×4^9+2×(9+8×7)", formatter.format(COMPLETE_EXPR))
assertEquals("((((((((", formatter.format(SOME_BRACKETS)) assertEquals("((((((((", formatter.format(SOME_BRACKETS))
} }
@ -73,7 +73,7 @@ class FormatterTest {
assertEquals("123.456,", formatter.format(INCOMPLETE_VALUE)) assertEquals("123.456,", formatter.format(INCOMPLETE_VALUE))
assertEquals("123.456", formatter.format(NO_FRACTIONAL_VALUE)) assertEquals("123.456", formatter.format(NO_FRACTIONAL_VALUE))
assertEquals("50+123.456÷8×0,812+", formatter.format(INCOMPLETE_EXPR)) assertEquals("50+123.456÷8×0,812+", formatter.format(INCOMPLETE_EXPR))
assertEquals("50+123.456÷8×0,812+0-√9*4^9+2×(9+8×7)", formatter.format(COMPLETE_EXPR)) assertEquals("50+123.456÷8×0,812+0√9×4^9+2×(9+8×7)", formatter.format(COMPLETE_EXPR))
assertEquals("((((((((", formatter.format(SOME_BRACKETS)) assertEquals("((((((((", formatter.format(SOME_BRACKETS))
} }

View File

@ -46,10 +46,13 @@ import com.sadellie.unitto.data.units.AllUnitsRepository
import com.sadellie.unitto.data.units.database.MyBasedUnitDatabase import com.sadellie.unitto.data.units.database.MyBasedUnitDatabase
import com.sadellie.unitto.data.units.database.MyBasedUnitsRepository import com.sadellie.unitto.data.units.database.MyBasedUnitsRepository
import com.sadellie.unitto.screens.main.MainViewModel import com.sadellie.unitto.screens.main.MainViewModel
import com.sadellie.unitto.testInViewModel import junit.framework.TestCase.assertEquals
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.collect
import org.junit.Assert.assertEquals import kotlinx.coroutines.launch
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.runTest
import org.junit.After
import org.junit.Before import org.junit.Before
import org.junit.Rule import org.junit.Rule
import org.junit.Test import org.junit.Test
@ -58,7 +61,6 @@ import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment import org.robolectric.RuntimeEnvironment
import org.robolectric.annotation.Config import org.robolectric.annotation.Config
@OptIn(ExperimentalCoroutinesApi::class) @OptIn(ExperimentalCoroutinesApi::class)
@Config(manifest = Config.NONE) @Config(manifest = Config.NONE)
@RunWith(RobolectricTestRunner::class) @RunWith(RobolectricTestRunner::class)
@ -70,114 +72,144 @@ class MainViewModelTest {
private lateinit var viewModel: MainViewModel private lateinit var viewModel: MainViewModel
private val allUnitsRepository = AllUnitsRepository() private val allUnitsRepository = AllUnitsRepository()
private val database = Room.inMemoryDatabaseBuilder(
RuntimeEnvironment.getApplication(),
MyBasedUnitDatabase::class.java
).build()
@Before @Before
fun setUp() { fun setUp() {
viewModel = MainViewModel( viewModel = MainViewModel(
UserPreferencesRepository( userPrefsRepository = UserPreferencesRepository(
DataStoreModule().provideUserPreferencesDataStore( DataStoreModule().provideUserPreferencesDataStore(
RuntimeEnvironment.getApplication() RuntimeEnvironment.getApplication()
) )
), MyBasedUnitsRepository( ),
Room.inMemoryDatabaseBuilder( basedUnitRepository = MyBasedUnitsRepository(
RuntimeEnvironment.getApplication(), MyBasedUnitDatabase::class.java database.myBasedUnitDao()
).build().myBasedUnitDao() ),
), RuntimeEnvironment.getApplication(), allUnitsRepository allUnitsRepository = allUnitsRepository
) )
} }
@Test @After
fun processInputTest() = testInViewModel { coroutineScope -> fun tearDown() {
viewModel.mainFlow.launchIn(coroutineScope) database.close()
/**
* For simplicity comments will have structure:
* currentInput | userInput | processed internal input | processed display output
*/
`test 0`()
`test digits from 1 to 9`()
`test plus, divide, multiply and exponent operators`()
`test dot`()
`test minus`()
`test brackets`()
`test square root`()
}
private fun `test 0`() {
inputOutputTest("0", "0", "0")
inputOutputTest("123000", "123000", "123000")
inputOutputTest("123.000", "123.000", "123.000")
inputOutputTest("-000", "-0", "0")
inputOutputTest("12+000", "12+0", "12+0")
inputOutputTest("√000", "√0", "√0")
inputOutputTest("(000", "(0", "(0")
inputOutputTest("(1+12)000", "(1+12)*0", "(1+12)×0")
inputOutputTest("(1.002+120)000", "(1.002+120)*0", "(1.002+120)×0")
}
private fun `test digits from 1 to 9`() {
inputOutputTest("123456789", "123456789", "123456789")
inputOutputTest("(1+1)111", "(1+1)*111", "(1+1)×111")
}
private fun `test plus, divide, multiply and exponent operators`() {
inputOutputTest("0+++", "0", "0")
inputOutputTest("123+++", "123+", "123+")
inputOutputTest("1-***", "1*", "1×")
inputOutputTest("1/-+++", "1+", "1+")
inputOutputTest("0^^^", "0", "0")
inputOutputTest("12^^^", "12^", "12^")
inputOutputTest("(^^^", "(", "(")
inputOutputTest("(8+9)^^^", "(8+9)^", "(8+9)^")
}
private fun `test dot`() {
inputOutputTest("0...", "0.", "0.")
inputOutputTest("1...", "1.", "1.")
inputOutputTest("1+...", "1+.", "1+.")
inputOutputTest("√...", "√.", "√.")
inputOutputTest("√21...", "√21.", "√21.")
inputOutputTest("√21+1.01-.23...", "√21+1.01-.23", "√21+1.01.23")
}
private fun `test minus`() {
inputOutputTest("0---", "-", "")
inputOutputTest("12---", "12-", "12")
inputOutputTest("12+---", "12-", "12")
inputOutputTest("12/---", "12/-", "12÷")
inputOutputTest("√---", "√-", "√–")
inputOutputTest("√///", "", "")
inputOutputTest("12^----", "12^-", "12^")
}
private fun `test brackets`() {
inputOutputTest("0)))", "0", "0")
inputOutputTest("0(((", "(((", "(((")
inputOutputTest("√(10+2)(", "√(10+2)*(", "√(10+2)×(")
inputOutputTest("√(10+2./(", "√(10+2./(", "√(10+2.÷(")
inputOutputTest("0()()))((", "((((", "((((")
inputOutputTest("√(10+2)^(", "√(10+2)^(", "√(10+2)^(")
}
private fun `test square root`() {
inputOutputTest("0√√√", "√√√", "√√√")
inputOutputTest("123√√√", "123*√√√", "123×√√√")
} }
@Test @Test
fun deleteSymbolTest() = testInViewModel { coroutineScope -> fun `test 0`() = runTest {
viewModel.mainFlow.launchIn(coroutineScope) val collectJob = launch(UnconfinedTestDispatcher(testScheduler)) {
viewModel.uiStateFlow.collect()
}
inputOutputTest("0", "0")
inputOutputTest("123000", "123000")
inputOutputTest("123.000", "123.000")
inputOutputTest("-000", "-0")
inputOutputTest("12+000", "12+0")
inputOutputTest("√000", "√0")
inputOutputTest("(000", "(0")
inputOutputTest("(1+12)000", "(1+12)*0")
inputOutputTest("(1.002+120)000", "(1.002+120)*0")
collectJob.cancel()
}
@Test
fun `test digits from 1 to 9`() = runTest {
val collectJob = launch(UnconfinedTestDispatcher(testScheduler)) {
viewModel.uiStateFlow.collect()
}
inputOutputTest("123456789", "123456789")
inputOutputTest("(1+1)111", "(1+1)*111")
collectJob.cancel()
}
@Test
fun `test plus, divide, multiply and exponent operators`() = runTest {
val collectJob = launch(UnconfinedTestDispatcher(testScheduler)) {
viewModel.uiStateFlow.collect()
}
inputOutputTest("0+++", "0")
inputOutputTest("123+++", "123+")
inputOutputTest("1-***", "1*")
inputOutputTest("1/-+++", "1+")
inputOutputTest("0^^^", "0")
inputOutputTest("12^^^", "12^")
inputOutputTest("(^^^", "(")
inputOutputTest("(8+9)^^^", "(8+9)^")
collectJob.cancel()
}
@Test
fun `test dot`() = runTest {
val collectJob = launch(UnconfinedTestDispatcher(testScheduler)) {
viewModel.uiStateFlow.collect()
}
inputOutputTest("0...", "0.")
inputOutputTest("1...", "1.")
inputOutputTest("1+...", "1+.")
inputOutputTest("√...", "√.")
inputOutputTest("√21...", "√21.")
inputOutputTest("√21+1.01-.23...", "√21+1.01-.23")
collectJob.cancel()
}
@Test
fun `test minus`() = runTest {
val collectJob = launch(UnconfinedTestDispatcher(testScheduler)) {
viewModel.uiStateFlow.collect()
}
inputOutputTest("0---", "-")
inputOutputTest("12---", "12-")
inputOutputTest("12+---", "12-")
inputOutputTest("12/---", "12/-")
inputOutputTest("√---", "√-")
inputOutputTest("√///", "")
inputOutputTest("12^----", "12^-")
collectJob.cancel()
}
@Test
fun `test brackets`() = runTest {
val collectJob = launch(UnconfinedTestDispatcher(testScheduler)) {
viewModel.uiStateFlow.collect()
}
inputOutputTest("0)))", "0")
inputOutputTest("0(((", "(((")
inputOutputTest("√(10+2)(", "√(10+2)*(")
inputOutputTest("√(10+2./(", "√(10+2./(")
inputOutputTest("0()()))((", "((((")
inputOutputTest("√(10+2)^(", "√(10+2)^(")
collectJob.cancel()
}
@Test
fun `test square root`() = runTest {
val collectJob = launch(UnconfinedTestDispatcher(testScheduler)) {
viewModel.uiStateFlow.collect()
}
inputOutputTest("0√√√", "√√√")
inputOutputTest("123√√√", "123*√√√")
collectJob.cancel()
}
@Test
fun deleteSymbolTest() = runTest {
val collectJob = launch(UnconfinedTestDispatcher(testScheduler)) {
viewModel.uiStateFlow.collect()
}
listOf( listOf(
KEY_1, KEY_2, KEY_3, KEY_4, KEY_5, KEY_6, KEY_7, KEY_8, KEY_9, KEY_0, KEY_1, KEY_2, KEY_3, KEY_4, KEY_5, KEY_6, KEY_7, KEY_8, KEY_9, KEY_0,
KEY_DOT, KEY_COMMA, KEY_LEFT_BRACKET, KEY_RIGHT_BRACKET, KEY_DOT, KEY_COMMA, KEY_LEFT_BRACKET, KEY_RIGHT_BRACKET,
KEY_PLUS, KEY_MINUS, KEY_DIVIDE, KEY_MULTIPLY, KEY_EXPONENT, KEY_SQRT, KEY_PLUS, KEY_MINUS, KEY_DIVIDE, KEY_MULTIPLY, KEY_EXPONENT, KEY_SQRT
).forEach { ).forEach {
// We enter one symbol and delete it, should be default as a result // We enter one symbol and delete it, should be default as a result
viewModel.processInput(it) viewModel.processInput(it)
viewModel.deleteDigit() viewModel.deleteDigit()
assertEquals("0", viewModel.mainFlow.value.inputValue) assertEquals("0", viewModel.uiStateFlow.value.inputValue)
assertEquals("0", viewModel.input.value)
} }
viewModel.clearInput() viewModel.clearInput()
@ -189,36 +221,53 @@ class MainViewModelTest {
viewModel.processInput(KEY_SQRT) viewModel.processInput(KEY_SQRT)
viewModel.processInput(KEY_9) viewModel.processInput(KEY_9)
viewModel.deleteDigit() viewModel.deleteDigit()
assertEquals("3*√", viewModel.input.value) assertEquals("3*√", viewModel.uiStateFlow.value.inputValue)
assertEquals("3×", viewModel.mainFlow.value.inputValue)
collectJob.cancel()
} }
@Test @Test
fun clearInputTest() = testInViewModel { coroutineScope -> fun clearInputTest() = runTest {
viewModel.mainFlow.launchIn(coroutineScope) val collectJob = launch(UnconfinedTestDispatcher(testScheduler)) {
viewModel.uiStateFlow.collect()
}
viewModel.processInput(KEY_3) viewModel.processInput(KEY_3)
viewModel.clearInput() viewModel.clearInput()
assertEquals(null, viewModel.mainFlow.value.calculatedValue) assertEquals(null, viewModel.uiStateFlow.value.calculatedValue)
viewModel.mainFlow.launchIn(coroutineScope)
viewModel.processInput(KEY_3) viewModel.processInput(KEY_3)
viewModel.processInput(KEY_MULTIPLY) viewModel.processInput(KEY_MULTIPLY)
viewModel.clearInput() viewModel.clearInput()
assertEquals(null, viewModel.mainFlow.value.calculatedValue) assertEquals(null, viewModel.uiStateFlow.value.calculatedValue)
collectJob.cancel()
}
@Test
fun swapUnitsTest() = runTest {
val collectJob = launch(UnconfinedTestDispatcher(testScheduler)) {
viewModel.uiStateFlow.collect()
}
val initialFrom = viewModel.uiStateFlow.value.unitFrom?.unitId
val initialTo = viewModel.uiStateFlow.value.unitTo?.unitId
viewModel.swapUnits()
assertEquals(initialTo, viewModel.uiStateFlow.value.unitFrom?.unitId)
assertEquals(initialFrom, viewModel.uiStateFlow.value.unitTo?.unitId)
collectJob.cancel()
} }
/** /**
* Takes [input] sequence as a single string (e.g. "123-23") and compares it with [output] * Takes [input] sequence as a single string (e.g. "123-23") and compares it with [output].
* (internal) and [outputDisplay] (the that user sees).
*/ */
private fun inputOutputTest(input: String, output: String, outputDisplay: String) { private fun inputOutputTest(input: String, output: String) {
// Enter everything // Enter everything
input.forEach { input.forEach {
viewModel.processInput(it.toString()) viewModel.processInput(it.toString())
} }
assertEquals(output, viewModel.input.value) assertEquals(output, viewModel.uiStateFlow.value.inputValue)
assertEquals(outputDisplay, viewModel.mainFlow.value.inputValue)
viewModel.clearInput() viewModel.clearInput()
} }
} }