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

View File

@ -21,16 +21,17 @@ package com.sadellie.unitto.data
import kotlinx.coroutines.flow.Flow
@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>,
flow2: Flow<T2>,
flow3: Flow<T3>,
flow4: Flow<T4>,
flow5: Flow<T5>,
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> =
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(
args[0] as T1,
args[1] as T2,
@ -38,5 +39,6 @@ fun <T1, T2, T3, T4, T5, T6, R> combine(
args[3] as T4,
args[4] as T5,
args[5] as T6,
args[6] as T7,
)
}

View File

@ -18,6 +18,7 @@
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_DOT
import com.sadellie.unitto.data.KEY_E
@ -81,6 +82,10 @@ object Formatter {
output = output.replace(it, formatNumber(it))
}
INTERNAL_DISPLAY.forEach {
output = output.replace(it.key, it.value)
}
return output
}

View File

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

View File

@ -19,24 +19,33 @@
package com.sadellie.unitto.screens.main
import com.sadellie.unitto.data.KEY_0
import com.sadellie.unitto.data.units.AbstractUnit
/**
* Represents current state of the MainScreen
*
* @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
* expression in input is invalid)
* @property isLoadingDatabase Whether we are loading data from Database. Need on app launch, once
* we are done loading list on Units list can be sorted by usage properly/
* @property isLoadingNetwork Whether we are loading data from network
* expression in input is invalid).
* @property resultValue Current output value.
* @property showLoading Whether we are 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(
var inputValue: String = KEY_0,
var resultValue: String = KEY_0,
var isLoadingDatabase: Boolean = true,
var isLoadingNetwork: Boolean = false,
var showError: Boolean = false,
var calculatedValue: String? = null
val inputValue: String = KEY_0,
val calculatedValue: String? = null,
val resultValue: String = KEY_0,
val showLoading: Boolean = true,
val showError: Boolean = false,
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
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.viewModelScope
import com.github.keelar.exprk.ExpressionException
import com.github.keelar.exprk.Expressions
import com.sadellie.unitto.FirebaseHelper
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_1
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.collectLatest
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.getAndUpdate
import kotlinx.coroutines.flow.merge
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.isActive
@ -84,77 +81,345 @@ import javax.inject.Inject
class MainViewModel @Inject constructor(
private val userPrefsRepository: UserPreferencesRepository,
private val basedUnitRepository: MyBasedUnitsRepository,
private val mContext: Application,
private val allUnitsRepository: AllUnitsRepository
) : 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(
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,
_result,
_isLoadingNetwork,
_isLoadingDatabase,
_showError,
) { inputValue: String,
calculatedValue: String?,
resultValue: String,
showLoadingNetwork: Boolean,
showLoadingDatabase: Boolean,
showError: Boolean ->
_showLoading,
_showError
) { inputValue, unitFromValue, unitToValue, calculatedValue, resultValue, showLoadingValue, showErrorValue ->
return@combine MainScreenUIState(
inputValue = inputValue,
calculatedValue = calculatedValue,
resultValue = resultValue,
isLoadingNetwork = showLoadingNetwork,
isLoadingDatabase = showLoadingDatabase,
showError = showError,
showLoading = showLoadingValue,
showError = showErrorValue,
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))
private set
fun processInput(symbolToAdd: String) {
// 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))
private set
fun updateUnitFrom(unit: AbstractUnit) {
// 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() {
withContext(Dispatchers.Default) {
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 {
(unitFrom as NumberBaseUnit).convertToBase(
input = input.value,
toBase = (unitTo as NumberBaseUnit).base,
input = _input.value,
toBase = (unitTo as NumberBaseUnit).base
)
} catch (e: Exception) {
when (e) {
is NumberFormatException, is IllegalArgumentException -> {
""
}
is ClassCastException -> {
cancel()
return@withContext
}
else -> {
throw e
}
is NumberFormatException, is IllegalArgumentException -> ""
else -> throw e
}
}
_result.update { conversionResult }
@ -166,13 +431,17 @@ class MainViewModel @Inject constructor(
private suspend fun convertAsExpression() {
withContext(Dispatchers.Default) {
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
var cleanInput = input.value.dropLastWhile { !it.isDigit() }
var cleanInput = _input.value.dropLastWhile { !it.isDigit() }
// Now we close open brackets that user didn't close
// AUTOCLOSE ALL BRACKETS
val leftBrackets = input.value.count { it.toString() == KEY_LEFT_BRACKET }
val rightBrackets = input.value.count { it.toString() == KEY_RIGHT_BRACKET }
val leftBrackets = _input.value.count { it.toString() == KEY_LEFT_BRACKET }
val rightBrackets = _input.value.count { it.toString() == KEY_RIGHT_BRACKET }
val neededBrackets = leftBrackets - rightBrackets
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-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
_calculated.update { null }
} else {
_calculated.update { evaluationResult.toStringWith(_userPrefs.value.outputFormat) }
_calculated.update {
evaluationResult.toStringWith(
_userPrefs.value.outputFormat
)
}
}
// Now we just convert.
// We can use evaluation result here, input is valid
val conversionResult: BigDecimal = unitFrom.convert(
unitTo, evaluationResult, _userPrefs.value.digitsPrecision
unitTo,
evaluationResult,
_userPrefs.value.digitsPrecision
)
// Converted
@ -221,398 +496,116 @@ class MainViewModel @Inject constructor(
}
}
/**
* This function takes local variables, converts values and then causes the UI to update
*/
private suspend fun convertInput() {
if (unitFrom.group == UnitGroup.NUMBER_BASE) {
convertAsNumberBase()
private fun setInputSymbols(symbol: String, add: Boolean = true) {
if (add) {
_input.update { it + symbol }
} else {
convertAsExpression()
// We don't need previous input, clear entirely
_latestInputStack.clear()
_input.update { symbol }
}
_latestInputStack.add(symbol)
}
/**
* Change left side unit. Unit to convert from
*
* @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
private fun incrementCounter(unit: AbstractUnit) {
viewModelScope.launch(Dispatchers.IO) {
basedUnitRepository.insertUnits(
MyBasedUnit(
unitId = unitFrom.unitId,
isFavorite = unitFrom.isFavorite,
pairedUnitId = unitFrom.pairedUnit,
frequency = unitFrom.counter
unitId = unit.unitId,
isFavorite = unit.isFavorite,
pairedUnitId = unit.pairedUnit,
// This will increment counter on unit in list too
frequency = ++unit.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
frequency = ++unit.counter
private fun updatePairedUnit(unit: AbstractUnit) {
viewModelScope.launch(Dispatchers.IO) {
basedUnitRepository.insertUnits(
MyBasedUnit(
unitId = unit.unitId,
isFavorite = unit.isFavorite,
pairedUnitId = unit.pairedUnit,
frequency = unit.counter
)
)
)
}
/**
* Updates basic units properties for all currencies, BUT only when [unitFrom]'s group is set
* to [UnitGroup.CURRENCY].
*/
private suspend fun updateCurrenciesBasicUnits() {
// Resetting error and network loading states in case we are not gonna do anything below
_isLoadingNetwork.update { false }
_showError.update { false }
// We update currencies only when needed
if (unitFrom.group != UnitGroup.CURRENCY) return
// Starting to load stuff
_isLoadingNetwork.update { true }
try {
val pairs: CurrencyUnitResponse =
CurrencyApi.retrofitService.getCurrencyPairs(unitFrom.unitId)
allUnitsRepository.updateBasicUnitsForCurrencies(pairs.currency)
} catch (e: Exception) {
when (e) {
// 403, Network and Adapter exceptions can be ignored
is retrofit2.HttpException, is java.net.UnknownHostException, is com.squareup.moshi.JsonDataException -> {}
else -> {
// Unexpected exception, should report it
FirebaseHelper().recordException(e)
}
}
_showError.update { true }
} finally {
// Loaded
_isLoadingNetwork.update { false }
}
}
/**
* Swaps measurement, left to right and vice versa
*/
fun swapUnits() {
unitFrom = unitTo.also {
unitTo = unitFrom
}
viewModelScope.launch {
updateCurrenciesBasicUnits()
saveLatestPairOfUnits()
}
}
private fun updateCurrenciesRatesIfNeeded() {
viewModelScope.launch(Dispatchers.IO) {
_showError.update { false }
_showLoading.update { false }
// 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
_showLoading.update { true }
/**
* 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)
}
try {
val pairs: CurrencyUnitResponse =
CurrencyApi.retrofitService.getCurrencyPairs(unitFrom.unitId)
allUnitsRepository.updateBasicUnitsForCurrencies(pairs.currency)
} catch (e: Exception) {
when (e) {
// 403, Network and Adapter exceptions can be ignored
is retrofit2.HttpException, is java.net.UnknownHostException, is com.squareup.moshi.JsonDataException -> {}
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)
// Unexpected exception, should report it
FirebaseHelper().recordException(e)
}
}
_showError.update { true }
} finally {
_showLoading.update { false }
}
}
}
/**
* 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) }
private fun saveLatestPairOfUnits() {
viewModelScope.launch(Dispatchers.IO) {
// Units are still loading, don't convert anything yet
val unitFrom = _unitFrom.value ?: return@launch
val unitTo = _unitTo.value ?: return@launch
userPrefsRepository.updateLatestPairOfUnits(unitFrom, unitTo)
}
}
/**
* 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)
}
}
}
/**
* Clears input value and sets it to default (ZERO)
*/
fun clearInput() {
setInputSymbols(KEY_0, false)
}
/**
* 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)
}
private fun startObserving() {
viewModelScope.launch(Dispatchers.Default) {
_userPrefs.collectLatest { convertInput() }
merge(_input, _unitFrom, _unitTo, _userPrefs).collectLatest { convertInput() }
}
viewModelScope.launch(Dispatchers.Default) {
input.collectLatest { convertInput() }
}
private fun loadInitialUnitPair() {
viewModelScope.launch(Dispatchers.IO) {
val initialUserPrefs = userPrefsRepository.userPreferencesFlow.first()
// First we load latest pair of units
_unitFrom.update {
try {
allUnitsRepository.getById(initialUserPrefs.latestLeftSideUnit)
} catch (e: java.util.NoSuchElementException) {
allUnitsRepository.getById(MyUnitIDS.kilometer)
}
}
_unitTo.update {
try {
allUnitsRepository.getById(initialUserPrefs.latestRightSideUnit)
} catch (e: java.util.NoSuchElementException) {
allUnitsRepository.getById(MyUnitIDS.mile)
}
}
updateCurrenciesRatesIfNeeded()
}
}
init {
viewModelScope.launch {
val initialUserPrefs = userPrefsRepository.userPreferencesFlow.first()
// First we load latest pair of units
unitFrom = try {
allUnitsRepository.getById(initialUserPrefs.latestLeftSideUnit)
} catch (e: java.util.NoSuchElementException) {
allUnitsRepository.getById(MyUnitIDS.kilometer)
}
unitTo = try {
allUnitsRepository.getById(initialUserPrefs.latestRightSideUnit)
} catch (e: java.util.NoSuchElementException) {
allUnitsRepository.getById(MyUnitIDS.mile)
}
// Now we load units data from database
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()
startObserving()
}
loadInitialUnitPair()
startObserving()
}
}

View File

@ -18,6 +18,7 @@
package com.sadellie.unitto.screens.main.components
import androidx.compose.animation.Crossfade
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
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_SQRT
import com.sadellie.unitto.screens.Formatter
import com.sadellie.unitto.screens.main.ConverterMode
/**
* 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 deleteDigit Function that is called when clicking delete "<" 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
fun Keyboard(
@ -71,7 +73,23 @@ fun Keyboard(
addDigit: (String) -> Unit = {},
deleteDigit: () -> 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(
modifier = modifier.fillMaxSize()
@ -83,67 +101,91 @@ fun Keyboard(
.padding(4.dp)
// Column modifier
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) {
KeyboardButton(bModifier, KEY_LEFT_BRACKET, isPrimary = false, onClick = addDigit)
KeyboardButton(bModifier, KEY_RIGHT_BRACKET, isPrimary = false, onClick = addDigit)
KeyboardButton(bModifier, KEY_EXPONENT, isPrimary = false, onClick = { addDigit(KEY_EXPONENT) })
KeyboardButton(bModifier, KEY_SQRT, isPrimary = false, onClick = { addDigit(KEY_SQRT) })
}
Row(cModifier) {
KeyboardButton(bModifier, KEY_7, onClick = addDigit)
KeyboardButton(bModifier, KEY_8, onClick = addDigit)
KeyboardButton(bModifier, KEY_9, onClick = addDigit)
KeyboardButton(bModifier, KEY_DIVIDE_DISPLAY, isPrimary = false) { addDigit(KEY_DIVIDE) }
}
Row(cModifier) {
KeyboardButton(bModifier, KEY_4, onClick = addDigit)
KeyboardButton(bModifier, KEY_5, onClick = addDigit)
KeyboardButton(bModifier, KEY_6, onClick = addDigit)
KeyboardButton(bModifier, KEY_MULTIPLY_DISPLAY, isPrimary = false) { addDigit(KEY_MULTIPLY) }
}
Row(cModifier) {
KeyboardButton(bModifier, KEY_1, onClick = addDigit)
KeyboardButton(bModifier, KEY_2, onClick = addDigit)
KeyboardButton(bModifier, KEY_3, onClick = addDigit)
KeyboardButton(bModifier, KEY_MINUS_DISPLAY, isPrimary = false) { addDigit(KEY_MINUS) }
}
Row(cModifier) {
KeyboardButton(bModifier, KEY_0, onClick = addDigit)
KeyboardButton(bModifier, Formatter.fractional) { addDigit(KEY_DOT) }
KeyboardButton(bModifier, KEY_CLEAR, onLongClick = clearInput) { deleteDigit() }
KeyboardButton(bModifier, KEY_PLUS, isPrimary = false) { addDigit(KEY_PLUS) }
}
Row(cModifier) {
KeyboardButton(bModifier, KEY_LEFT_BRACKET, isPrimary = false, onClick = addDigit)
KeyboardButton(bModifier, KEY_RIGHT_BRACKET, isPrimary = false, onClick = addDigit)
KeyboardButton(bModifier, KEY_EXPONENT, isPrimary = false, onClick = { addDigit(KEY_EXPONENT) })
KeyboardButton(bModifier, KEY_SQRT, isPrimary = false, onClick = { addDigit(KEY_SQRT) })
}
Row(cModifier) {
KeyboardButton(bModifier, KEY_7, onClick = addDigit)
KeyboardButton(bModifier, KEY_8, onClick = addDigit)
KeyboardButton(bModifier, KEY_9, onClick = addDigit)
KeyboardButton(bModifier, KEY_DIVIDE_DISPLAY, isPrimary = false) { addDigit(KEY_DIVIDE) }
}
Row(cModifier) {
KeyboardButton(bModifier, KEY_4, onClick = addDigit)
KeyboardButton(bModifier, KEY_5, onClick = addDigit)
KeyboardButton(bModifier, KEY_6, onClick = addDigit)
KeyboardButton(bModifier, KEY_MULTIPLY_DISPLAY, isPrimary = false) { addDigit(KEY_MULTIPLY) }
}
Row(cModifier) {
KeyboardButton(bModifier, KEY_1, onClick = addDigit)
KeyboardButton(bModifier, KEY_2, onClick = addDigit)
KeyboardButton(bModifier, KEY_3, onClick = addDigit)
KeyboardButton(bModifier, KEY_MINUS_DISPLAY, isPrimary = false) { addDigit(KEY_MINUS) }
}
Row(cModifier) {
KeyboardButton(bModifier, KEY_0, onClick = addDigit)
KeyboardButton(bModifier, Formatter.fractional) { addDigit(KEY_DOT) }
KeyboardButton(bModifier, KEY_CLEAR, onLongClick = clearInput) { deleteDigit() }
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.units.AbstractUnit
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.
* 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 inputValue Current input value (like big decimal)
* @param modifier Modifier that is applied to Column.
* @param inputValue Current input value (like big decimal).
* @param calculatedValue Current calculated value (like big decimal), will be shown under input when it
* has an expression in it.
* @param outputValue Current output value (like big decimal)
* @param unitFrom [AbstractUnit] on the left
* @param unitTo [AbstractUnit] on the right
* @param loadingDatabase Are we still loading units usage stats from database? Disables unit
* selection buttons.
* @param loadingNetwork Are we loading data from network? Shows loading text in TextFields
* @param networkError Did we got errors while trying to get data from network
* @param onUnitSelectionClick Function that is called when clicking unit selection buttons
* @param swapUnits Method to swap units
* @param outputValue Current output value (like big decimal).
* @param unitFrom [AbstractUnit] on the left.
* @param unitTo [AbstractUnit] on the right.
* @param loadingNetwork Are we loading data from network? Shows loading text in TextFields.
* @param networkError Did we got errors while trying to get data from network.
* @param onUnitSelectionClick Function that is called when clicking unit selection buttons.
* @param swapUnits Method to swap units.
* @param converterMode [ConverterMode.BASE] doesn't use formatting for input/output.
*/
@Composable
fun TopScreenPart(
@ -70,14 +70,13 @@ fun TopScreenPart(
inputValue: String,
calculatedValue: String?,
outputValue: String,
unitFrom: AbstractUnit,
unitTo: AbstractUnit,
loadingDatabase: Boolean,
unitFrom: AbstractUnit?,
unitTo: AbstractUnit?,
loadingNetwork: Boolean,
networkError: Boolean,
onUnitSelectionClick: (String) -> Unit,
swapUnits: () -> Unit,
baseConverterMode: Boolean,
converterMode: ConverterMode,
) {
var swapped by remember { mutableStateOf(false) }
val swapButtonRotation: Float by animateFloatAsState(
@ -93,28 +92,28 @@ fun TopScreenPart(
modifier = Modifier.fillMaxWidth(),
primaryText = {
when {
loadingDatabase || loadingNetwork -> stringResource(R.string.loading_label)
loadingNetwork -> stringResource(R.string.loading_label)
networkError -> stringResource(R.string.error_label)
baseConverterMode -> inputValue.uppercase()
converterMode == ConverterMode.BASE -> inputValue.uppercase()
else -> Formatter.format(inputValue)
}
},
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,
)
MyTextField(
modifier = Modifier.fillMaxWidth(),
primaryText = {
when {
loadingDatabase || loadingNetwork -> stringResource(R.string.loading_label)
loadingNetwork -> stringResource(R.string.loading_label)
networkError -> stringResource(R.string.error_label)
baseConverterMode -> outputValue.uppercase()
converterMode == ConverterMode.BASE -> outputValue.uppercase()
else -> Formatter.format(outputValue)
}
},
secondaryText = null,
helperText = stringResource(if (loadingDatabase) R.string.loading_label else unitTo.shortName),
helperText = stringResource(unitTo?.shortName ?: R.string.loading_label),
textToCopy = outputValue,
)
// Unit selection buttons
@ -127,15 +126,14 @@ fun TopScreenPart(
.fillMaxWidth()
.weight(1f),
onClick = { onUnitSelectionClick(LEFT_LIST_SCREEN) },
label = unitFrom.displayName,
isLoading = loadingDatabase
label = unitFrom?.displayName ?: R.string.loading_label,
)
IconButton(
onClick = {
swapUnits()
swapped = !swapped
},
enabled = !loadingDatabase
enabled = unitFrom != null
) {
Icon(
modifier = Modifier.rotate(swapButtonRotation),
@ -148,8 +146,7 @@ fun TopScreenPart(
.fillMaxWidth()
.weight(1f),
onClick = { onUnitSelectionClick(RIGHT_LIST_SCREEN) },
label = unitTo.displayName,
isLoading = loadingDatabase
label = unitTo?.displayName ?: R.string.loading_label,
)
}
}

View File

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

View File

@ -18,6 +18,7 @@
package com.sadellie.unitto.screens.second
import android.app.Application
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.sadellie.unitto.data.units.AbstractUnit
@ -41,7 +42,8 @@ import javax.inject.Inject
class SecondViewModel @Inject constructor(
private val basedUnitRepository: MyBasedUnitsRepository,
private val allUnitsRepository: AllUnitsRepository,
unitGroupsRepository: UnitGroupsRepository
private val mContext: Application,
unitGroupsRepository: UnitGroupsRepository,
) : ViewModel() {
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(NO_FRACTIONAL_VALUE))
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))
}
@ -59,7 +59,7 @@ class FormatterTest {
assertEquals("123,456.", formatter.format(INCOMPLETE_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+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))
}
@ -73,7 +73,7 @@ class FormatterTest {
assertEquals("123.456,", formatter.format(INCOMPLETE_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+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))
}

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.MyBasedUnitsRepository
import com.sadellie.unitto.screens.main.MainViewModel
import com.sadellie.unitto.testInViewModel
import junit.framework.TestCase.assertEquals
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.launchIn
import org.junit.Assert.assertEquals
import kotlinx.coroutines.flow.collect
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.Rule
import org.junit.Test
@ -58,7 +61,6 @@ import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
import org.robolectric.annotation.Config
@OptIn(ExperimentalCoroutinesApi::class)
@Config(manifest = Config.NONE)
@RunWith(RobolectricTestRunner::class)
@ -70,114 +72,144 @@ class MainViewModelTest {
private lateinit var viewModel: MainViewModel
private val allUnitsRepository = AllUnitsRepository()
private val database = Room.inMemoryDatabaseBuilder(
RuntimeEnvironment.getApplication(),
MyBasedUnitDatabase::class.java
).build()
@Before
fun setUp() {
viewModel = MainViewModel(
UserPreferencesRepository(
userPrefsRepository = UserPreferencesRepository(
DataStoreModule().provideUserPreferencesDataStore(
RuntimeEnvironment.getApplication()
)
), MyBasedUnitsRepository(
Room.inMemoryDatabaseBuilder(
RuntimeEnvironment.getApplication(), MyBasedUnitDatabase::class.java
).build().myBasedUnitDao()
), RuntimeEnvironment.getApplication(), allUnitsRepository
),
basedUnitRepository = MyBasedUnitsRepository(
database.myBasedUnitDao()
),
allUnitsRepository = allUnitsRepository
)
}
@Test
fun processInputTest() = testInViewModel { coroutineScope ->
viewModel.mainFlow.launchIn(coroutineScope)
/**
* 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×√√√")
@After
fun tearDown() {
database.close()
}
@Test
fun deleteSymbolTest() = testInViewModel { coroutineScope ->
viewModel.mainFlow.launchIn(coroutineScope)
fun `test 0`() = runTest {
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(
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_PLUS, KEY_MINUS, KEY_DIVIDE, KEY_MULTIPLY, KEY_EXPONENT, KEY_SQRT,
KEY_PLUS, KEY_MINUS, KEY_DIVIDE, KEY_MULTIPLY, KEY_EXPONENT, KEY_SQRT
).forEach {
// We enter one symbol and delete it, should be default as a result
viewModel.processInput(it)
viewModel.deleteDigit()
assertEquals("0", viewModel.mainFlow.value.inputValue)
assertEquals("0", viewModel.input.value)
assertEquals("0", viewModel.uiStateFlow.value.inputValue)
}
viewModel.clearInput()
@ -189,36 +221,53 @@ class MainViewModelTest {
viewModel.processInput(KEY_SQRT)
viewModel.processInput(KEY_9)
viewModel.deleteDigit()
assertEquals("3*√", viewModel.input.value)
assertEquals("3×", viewModel.mainFlow.value.inputValue)
assertEquals("3*√", viewModel.uiStateFlow.value.inputValue)
collectJob.cancel()
}
@Test
fun clearInputTest() = testInViewModel { coroutineScope ->
viewModel.mainFlow.launchIn(coroutineScope)
fun clearInputTest() = runTest {
val collectJob = launch(UnconfinedTestDispatcher(testScheduler)) {
viewModel.uiStateFlow.collect()
}
viewModel.processInput(KEY_3)
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_MULTIPLY)
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]
* (internal) and [outputDisplay] (the that user sees).
* Takes [input] sequence as a single string (e.g. "123-23") and compares it with [output].
*/
private fun inputOutputTest(input: String, output: String, outputDisplay: String) {
private fun inputOutputTest(input: String, output: String) {
// Enter everything
input.forEach {
viewModel.processInput(it.toString())
}
assertEquals(output, viewModel.input.value)
assertEquals(outputDisplay, viewModel.mainFlow.value.inputValue)
assertEquals(output, viewModel.uiStateFlow.value.inputValue)
viewModel.clearInput()
}
}