Refactor unit selector screens

decrease duplicate logic in ConverterViewModel
This commit is contained in:
Sad Ellie 2024-02-09 23:25:08 +03:00
parent 9d76c168ec
commit 7cbbb846af
10 changed files with 404 additions and 273 deletions

View File

@ -154,5 +154,45 @@ fun <T1, T2, T3, T4, T5, T6, T7, T8, T9, R> combine(
) )
} }
@Suppress("UNCHECKED_CAST", "UNUSED")
fun <T1, T2, T3, T4, T5, T6, T7, T8, T9, T10, R> combine(
flow: Flow<T1>,
flow2: Flow<T2>,
flow3: Flow<T3>,
flow4: Flow<T4>,
flow5: Flow<T5>,
flow6: Flow<T6>,
flow7: Flow<T7>,
flow8: Flow<T8>,
flow9: Flow<T9>,
flow10: Flow<T10>,
transform: suspend (T1, T2, T3, T4, T5, T6, T7, T8, T9, T10) -> R,
): Flow<R> =
kotlinx.coroutines.flow.combine(
flow,
flow2,
flow3,
flow4,
flow5,
flow6,
flow7,
flow8,
flow9,
flow10
) { args: Array<*> ->
transform(
args[0] as T1,
args[1] as T2,
args[2] as T3,
args[3] as T4,
args[4] as T5,
args[5] as T6,
args[6] as T7,
args[7] as T8,
args[8] as T9,
args[9] as T10,
)
}
fun <T> Flow<T>.stateIn(scope: CoroutineScope, initialValue: T): StateFlow<T> = fun <T> Flow<T>.stateIn(scope: CoroutineScope, initialValue: T): StateFlow<T> =
stateIn(scope, SharingStarted.WhileSubscribed(5000L), initialValue) stateIn(scope, SharingStarted.WhileSubscribed(5000L), initialValue)

View File

@ -97,8 +97,8 @@ import java.util.Locale
@Composable @Composable
internal fun ConverterRoute( internal fun ConverterRoute(
viewModel: ConverterViewModel = hiltViewModel(), viewModel: ConverterViewModel = hiltViewModel(),
navigateToLeftScreen: () -> Unit, navigateToLeftScreen: (uiState: UnitConverterUIState) -> Unit,
navigateToRightScreen: () -> Unit, navigateToRightScreen: (uiState: UnitConverterUIState) -> Unit,
navigateToMenu: () -> Unit, navigateToMenu: () -> Unit,
navigateToSettings: () -> Unit, navigateToSettings: () -> Unit,
) { ) {
@ -124,8 +124,8 @@ internal fun ConverterRoute(
@Composable @Composable
private fun ConverterScreen( private fun ConverterScreen(
uiState: UnitConverterUIState, uiState: UnitConverterUIState,
navigateToLeftScreen: () -> Unit, navigateToLeftScreen: (uiState: UnitConverterUIState) -> Unit,
navigateToRightScreen: () -> Unit, navigateToRightScreen: (uiState: UnitConverterUIState) -> Unit,
navigateToSettings: () -> Unit, navigateToSettings: () -> Unit,
navigateToMenu: () -> Unit, navigateToMenu: () -> Unit,
swapUnits: () -> Unit, swapUnits: () -> Unit,
@ -207,9 +207,9 @@ private fun NumberBase(
onValueChange: (TextFieldValue) -> Unit, onValueChange: (TextFieldValue) -> Unit,
processInput: (String) -> Unit, processInput: (String) -> Unit,
deleteDigit: () -> Unit, deleteDigit: () -> Unit,
navigateToLeftScreen: () -> Unit, navigateToLeftScreen: (uiState: UnitConverterUIState) -> Unit,
swapUnits: () -> Unit, swapUnits: () -> Unit,
navigateToRightScreen: () -> Unit, navigateToRightScreen: (uiState: UnitConverterUIState) -> Unit,
clearInput: () -> Unit, clearInput: () -> Unit,
) { ) {
PortraitLandscape( PortraitLandscape(
@ -239,8 +239,8 @@ private fun NumberBase(
unitFromLabel = stringResource(uiState.unitFrom.displayName), unitFromLabel = stringResource(uiState.unitFrom.displayName),
unitToLabel = stringResource(uiState.unitTo.displayName), unitToLabel = stringResource(uiState.unitTo.displayName),
swapUnits = swapUnits, swapUnits = swapUnits,
navigateToLeftScreen = navigateToLeftScreen, navigateToLeftScreen = { navigateToLeftScreen(uiState) },
navigateToRightScreen = navigateToRightScreen navigateToRightScreen = { navigateToRightScreen(uiState) }
) )
} }
}, },
@ -263,9 +263,9 @@ private fun Default(
onFocusOnInput2: (Boolean) -> Unit, onFocusOnInput2: (Boolean) -> Unit,
processInput: (String) -> Unit, processInput: (String) -> Unit,
deleteDigit: () -> Unit, deleteDigit: () -> Unit,
navigateToLeftScreen: () -> Unit, navigateToLeftScreen: (uiState: UnitConverterUIState) -> Unit,
swapUnits: () -> Unit, swapUnits: () -> Unit,
navigateToRightScreen: () -> Unit, navigateToRightScreen: (uiState: UnitConverterUIState) -> Unit,
clearInput: () -> Unit, clearInput: () -> Unit,
refreshCurrencyRates: (AbstractUnit) -> Unit, refreshCurrencyRates: (AbstractUnit) -> Unit,
addBracket: () -> Unit, addBracket: () -> Unit,
@ -327,7 +327,9 @@ private fun Default(
.weight(1f) .weight(1f)
) { ) {
ExpressionTextField( ExpressionTextField(
modifier = Modifier.fillMaxWidth().weight(1f), modifier = Modifier
.fillMaxWidth()
.weight(1f),
value = uiState.input1, value = uiState.input1,
minRatio = 0.7f, minRatio = 0.7f,
onValueChange = onValueChange, onValueChange = onValueChange,
@ -345,7 +347,9 @@ private fun Default(
.weight(1f) .weight(1f)
) { ) {
ExpressionTextField( ExpressionTextField(
modifier = Modifier.fillMaxWidth().weight(1f) modifier = Modifier
.fillMaxWidth()
.weight(1f)
.onFocusEvent { state -> onFocusOnInput2(state.hasFocus) }, .onFocusEvent { state -> onFocusOnInput2(state.hasFocus) },
value = uiState.input2, value = uiState.input2,
minRatio = 0.7f, minRatio = 0.7f,
@ -405,8 +409,8 @@ private fun Default(
unitFromLabel = stringResource(uiState.unitFrom.displayName), unitFromLabel = stringResource(uiState.unitFrom.displayName),
unitToLabel = stringResource(uiState.unitTo.displayName), unitToLabel = stringResource(uiState.unitTo.displayName),
swapUnits = swapUnits, swapUnits = swapUnits,
navigateToLeftScreen = navigateToLeftScreen, navigateToLeftScreen = { navigateToLeftScreen(uiState) },
navigateToRightScreen = navigateToRightScreen navigateToRightScreen = { navigateToRightScreen(uiState) }
) )
} }
}, },

View File

@ -33,7 +33,6 @@ import com.sadellie.unitto.data.common.isExpression
import com.sadellie.unitto.data.common.stateIn import com.sadellie.unitto.data.common.stateIn
import com.sadellie.unitto.data.converter.UnitID import com.sadellie.unitto.data.converter.UnitID
import com.sadellie.unitto.data.model.UnitGroup import com.sadellie.unitto.data.model.UnitGroup
import com.sadellie.unitto.data.model.UnitsListSorting
import com.sadellie.unitto.data.model.repository.UnitsRepository import com.sadellie.unitto.data.model.repository.UnitsRepository
import com.sadellie.unitto.data.model.repository.UserPreferencesRepository import com.sadellie.unitto.data.model.repository.UserPreferencesRepository
import com.sadellie.unitto.data.model.unit.AbstractUnit import com.sadellie.unitto.data.model.unit.AbstractUnit
@ -45,12 +44,10 @@ import io.github.sadellie.evaluatto.ExpressionException
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.getAndUpdate import kotlinx.coroutines.flow.getAndUpdate
import kotlinx.coroutines.flow.mapLatest import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
@ -74,13 +71,6 @@ internal class ConverterViewModel @Inject constructor(
private val _unitFrom = MutableStateFlow<AbstractUnit?>(null) private val _unitFrom = MutableStateFlow<AbstractUnit?>(null)
private val _unitTo = MutableStateFlow<AbstractUnit?>(null) private val _unitTo = MutableStateFlow<AbstractUnit?>(null)
private val _leftQuery = MutableStateFlow(TextFieldValue())
private val _leftUnits = MutableStateFlow<Map<UnitGroup, List<AbstractUnit>>>(emptyMap())
private val _leftUnitGroup = MutableStateFlow<UnitGroup?>(null)
private val _rightQuery = MutableStateFlow(TextFieldValue())
private val _rightUnits = MutableStateFlow<Map<UnitGroup, List<AbstractUnit>>>(emptyMap())
private val _currenciesState = MutableStateFlow<CurrencyRateUpdateState>(CurrencyRateUpdateState.Nothing) private val _currenciesState = MutableStateFlow<CurrencyRateUpdateState>(CurrencyRateUpdateState.Nothing)
private var _loadCurrenciesJob: Job? = null private var _loadCurrenciesJob: Job? = null
@ -160,81 +150,6 @@ internal class ConverterViewModel @Inject constructor(
} }
.stateIn(viewModelScope, UnitConverterUIState.Loading) .stateIn(viewModelScope, UnitConverterUIState.Loading)
val leftSideUIState = combine(
_unitFrom,
_leftQuery,
_leftUnits,
_leftUnitGroup,
userPrefsRepository.converterPrefs,
unitsRepo.units
) { unitFrom, query, units, unitGroup, prefs, _ ->
unitFrom ?: return@combine LeftSideUIState.Loading
return@combine LeftSideUIState.Ready(
unitFrom = unitFrom,
sorting = prefs.unitConverterSorting,
shownUnitGroups = prefs.shownUnitGroups,
favorites = prefs.unitConverterFavoritesOnly,
query = query,
units = units,
unitGroup = unitGroup
)
}
.mapLatest {
if (it !is LeftSideUIState.Ready) return@mapLatest it
filterUnitsLeft(
query = it.query,
unitGroup = it.unitGroup,
favoritesOnly = it.favorites,
sorting = it.sorting,
shownUnitGroups = it.shownUnitGroups,
)
it
}
.stateIn(viewModelScope, SharingStarted.Lazily, LeftSideUIState.Loading)
val rightSideUIState = combine(
_unitFrom,
_unitTo,
_input1,
_calculation,
_rightQuery,
_rightUnits,
userPrefsRepository.converterPrefs,
_currenciesState,
unitsRepo.units,
) { unitFrom, unitTo, input, calculation, query, units, prefs, currenciesState, _ ->
unitFrom ?: return@combine RightSideUIState.Loading
unitTo ?: return@combine RightSideUIState.Loading
return@combine RightSideUIState.Ready(
unitFrom = unitFrom,
unitTo = unitTo,
sorting = prefs.unitConverterSorting,
favorites = prefs.unitConverterFavoritesOnly,
input = (calculation?.toPlainString() ?: input.text).replace(Token.Operator.minus, "-"),
scale = prefs.precision,
outputFormat = prefs.outputFormat,
formatterSymbols = AllFormatterSymbols.getById(prefs.separator),
currencyRateUpdateState = currenciesState,
query = query,
units = units,
)
}
.mapLatest {
if (it !is RightSideUIState.Ready) return@mapLatest it
filterUnitsRight(
query = it.query,
unitGroup = it.unitFrom.group,
favoritesOnly = it.favorites,
sorting = it.sorting,
)
it
}
.stateIn(viewModelScope, SharingStarted.Lazily, RightSideUIState.Loading)
fun swapUnits() { fun swapUnits() {
_unitFrom _unitFrom
.getAndUpdate { _unitTo.value } .getAndUpdate { _unitTo.value }
@ -380,56 +295,6 @@ internal class ConverterViewModel @Inject constructor(
} }
} }
fun queryChangeLeft(query: TextFieldValue) = _leftQuery.update { query }
fun queryChangeRight(query: TextFieldValue) = _rightQuery.update { query }
fun favoritesOnlyChange(enabled: Boolean) = viewModelScope.launch {
userPrefsRepository.updateUnitConverterFavoritesOnly(enabled)
}
fun updateUnitGroupLeft(unitGroup: UnitGroup?) = _leftUnitGroup.update { unitGroup }
fun favoriteUnit(unit: AbstractUnit) = viewModelScope.launch {
unitsRepo.favorite(unit)
}
private fun filterUnitsLeft(
query: TextFieldValue,
unitGroup: UnitGroup?,
favoritesOnly: Boolean,
sorting: UnitsListSorting,
shownUnitGroups: List<UnitGroup>,
) = viewModelScope.launch(Dispatchers.Default) {
_leftUnits.update {
unitsRepo.filterUnits(
query = query.text,
unitGroup = unitGroup,
favoritesOnly = favoritesOnly,
hideBrokenUnits = false,
sorting = sorting,
shownUnitGroups = shownUnitGroups
)
}
}
private fun filterUnitsRight(
query: TextFieldValue,
unitGroup: UnitGroup?,
favoritesOnly: Boolean,
sorting: UnitsListSorting,
) = viewModelScope.launch(Dispatchers.Default) {
_rightUnits.update {
unitsRepo.filterUnits(
query = query.text,
unitGroup = unitGroup,
favoritesOnly = favoritesOnly,
hideBrokenUnits = true,
sorting = sorting,
)
}
}
private fun convertDefault( private fun convertDefault(
unitFrom: DefaultUnit, unitFrom: DefaultUnit,
unitTo: DefaultUnit, unitTo: DefaultUnit,

View File

@ -1,38 +0,0 @@
/*
* Unitto is a calculator for Android
* Copyright (c) 2023-2024 Elshan Agaev
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.sadellie.unitto.feature.converter
import androidx.compose.ui.text.input.TextFieldValue
import com.sadellie.unitto.data.model.UnitGroup
import com.sadellie.unitto.data.model.UnitsListSorting
import com.sadellie.unitto.data.model.unit.AbstractUnit
internal sealed class LeftSideUIState {
data object Loading : LeftSideUIState()
data class Ready(
val unitFrom: AbstractUnit,
val query: TextFieldValue,
val units: Map<UnitGroup, List<AbstractUnit>> = emptyMap(),
val favorites: Boolean,
val shownUnitGroups: List<UnitGroup>,
val unitGroup: UnitGroup?,
val sorting: UnitsListSorting,
) : LeftSideUIState()
}

View File

@ -49,31 +49,32 @@ import com.sadellie.unitto.feature.converter.components.UnitsList
import java.math.BigDecimal import java.math.BigDecimal
@Composable @Composable
internal fun LeftSideRoute( internal fun UnitFromSelectorRoute(
viewModel: ConverterViewModel, unitSelectorViewModel: UnitSelectorViewModel,
converterViewModel: ConverterViewModel,
navigateUp: () -> Unit, navigateUp: () -> Unit,
navigateToUnitGroups: () -> Unit, navigateToUnitGroups: () -> Unit,
) { ) {
when ( when (
val uiState = viewModel.leftSideUIState.collectAsStateWithLifecycle().value val uiState = unitSelectorViewModel.unitFromUIState.collectAsStateWithLifecycle().value
) { ) {
is LeftSideUIState.Loading -> EmptyScreen() is UnitSelectorUIState.UnitFrom -> UnitFromSelectorScreen(
is LeftSideUIState.Ready -> LeftSideScreen(
uiState = uiState, uiState = uiState,
onQueryChange = viewModel::queryChangeLeft, onQueryChange = unitSelectorViewModel::updateSelectorQuery,
toggleFavoritesOnly = viewModel::favoritesOnlyChange, toggleFavoritesOnly = unitSelectorViewModel::updateShowFavoritesOnly,
updateUnitFrom = viewModel::updateUnitFrom, updateUnitFrom = converterViewModel::updateUnitFrom,
updateUnitGroup = viewModel::updateUnitGroupLeft, updateUnitGroup = unitSelectorViewModel::updateSelectedUnitGroup,
favoriteUnit = viewModel::favoriteUnit, favoriteUnit = unitSelectorViewModel::favoriteUnit,
navigateUp = navigateUp, navigateUp = navigateUp,
navigateToUnitGroups = navigateToUnitGroups, navigateToUnitGroups = navigateToUnitGroups,
) )
else -> EmptyScreen()
} }
} }
@Composable @Composable
private fun LeftSideScreen( private fun UnitFromSelectorScreen(
uiState: LeftSideUIState.Ready, uiState: UnitSelectorUIState.UnitFrom,
onQueryChange: (TextFieldValue) -> Unit, onQueryChange: (TextFieldValue) -> Unit,
toggleFavoritesOnly: (Boolean) -> Unit, toggleFavoritesOnly: (Boolean) -> Unit,
updateUnitFrom: (AbstractUnit) -> Unit, updateUnitFrom: (AbstractUnit) -> Unit,
@ -87,8 +88,6 @@ private fun LeftSideScreen(
val chipsRowLazyListState = rememberLazyListState() val chipsRowLazyListState = rememberLazyListState()
LaunchedEffect(uiState.unitFrom, uiState.shownUnitGroups) { LaunchedEffect(uiState.unitFrom, uiState.shownUnitGroups) {
updateUnitGroup(uiState.unitFrom.group)
kotlin.runCatching { kotlin.runCatching {
val groupToSelect = uiState.shownUnitGroups.indexOf(uiState.unitFrom.group) val groupToSelect = uiState.shownUnitGroups.indexOf(uiState.unitFrom.group)
if (groupToSelect > -1) { if (groupToSelect > -1) {
@ -108,8 +107,8 @@ private fun LeftSideScreen(
onQueryChange = onQueryChange, onQueryChange = onQueryChange,
navigateUp = navigateUp, navigateUp = navigateUp,
trailingIcon = { trailingIcon = {
FavoritesButton(uiState.favorites) { FavoritesButton(uiState.showFavoritesOnly) {
toggleFavoritesOnly(!uiState.favorites) toggleFavoritesOnly(!uiState.showFavoritesOnly)
} }
}, },
scrollBehavior = scrollBehavior scrollBehavior = scrollBehavior
@ -119,7 +118,7 @@ private fun LeftSideScreen(
modifier = Modifier modifier = Modifier
.padding(start = 8.dp, end = 8.dp, bottom = 4.dp) .padding(start = 8.dp, end = 8.dp, bottom = 4.dp)
.fillMaxWidth(), .fillMaxWidth(),
chosenUnitGroup = uiState.unitGroup, chosenUnitGroup = uiState.selectedUnitGroup,
items = uiState.shownUnitGroups, items = uiState.shownUnitGroups,
selectAction = updateUnitGroup, selectAction = updateUnitGroup,
navigateToSettingsAction = navigateToUnitGroups navigateToSettingsAction = navigateToUnitGroups
@ -130,7 +129,7 @@ private fun LeftSideScreen(
val resources = LocalContext.current.resources val resources = LocalContext.current.resources
UnitsList( UnitsList(
modifier = Modifier.padding(paddingValues), modifier = Modifier.padding(paddingValues),
groupedUnits = uiState.units, searchResult = uiState.units,
navigateToUnitGroups = navigateToUnitGroups, navigateToUnitGroups = navigateToUnitGroups,
currentUnitId = uiState.unitFrom.id, currentUnitId = uiState.unitFrom.id,
supportLabel = { resources.getString(it.shortName) }, supportLabel = { resources.getString(it.shortName) },
@ -146,7 +145,7 @@ private fun LeftSideScreen(
@Preview @Preview
@Composable @Composable
private fun LeftSideScreenPreview() { private fun UnitFromSelectorScreenPreview() {
val units: Map<UnitGroup, List<AbstractUnit>> = mapOf( val units: Map<UnitGroup, List<AbstractUnit>> = mapOf(
UnitGroup.LENGTH to listOf( UnitGroup.LENGTH to listOf(
NormalUnit(UnitID.meter, BigDecimal.valueOf(1.0E+18), UnitGroup.LENGTH, R.string.unit_meter, R.string.unit_meter_short), NormalUnit(UnitID.meter, BigDecimal.valueOf(1.0E+18), UnitGroup.LENGTH, R.string.unit_meter, R.string.unit_meter_short),
@ -159,14 +158,14 @@ private fun LeftSideScreenPreview() {
) )
) )
LeftSideScreen( UnitFromSelectorScreen(
uiState = LeftSideUIState.Ready( uiState = UnitSelectorUIState.UnitFrom(
unitFrom = units.values.first().first(), unitFrom = units.values.first().first(),
units = units,
query = TextFieldValue("test"), query = TextFieldValue("test"),
favorites = false, units = UnitSearchResult.Success(units),
selectedUnitGroup = UnitGroup.SPEED,
shownUnitGroups = UnitGroup.entries, shownUnitGroups = UnitGroup.entries,
unitGroup = units.keys.toList().first(), showFavoritesOnly = false,
sorting = UnitsListSorting.USAGE, sorting = UnitsListSorting.USAGE,
), ),
onQueryChange = {}, onQueryChange = {},

View File

@ -1,6 +1,6 @@
/* /*
* Unitto is a calculator for Android * Unitto is a calculator for Android
* Copyright (c) 2023-2024 Elshan Agaev * Copyright (c) 2024 Elshan Agaev
* *
* This program is free software: you can redistribute it and/or modify * This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by * it under the terms of the GNU General Public License as published by
@ -24,20 +24,39 @@ import com.sadellie.unitto.data.model.UnitGroup
import com.sadellie.unitto.data.model.UnitsListSorting import com.sadellie.unitto.data.model.UnitsListSorting
import com.sadellie.unitto.data.model.unit.AbstractUnit import com.sadellie.unitto.data.model.unit.AbstractUnit
internal sealed class RightSideUIState { internal sealed class UnitSelectorUIState {
data object Loading : RightSideUIState() data object Loading : UnitSelectorUIState()
data class Ready( data class UnitFrom(
val query: TextFieldValue,
val unitFrom: AbstractUnit,
val shownUnitGroups: List<UnitGroup>,
val showFavoritesOnly: Boolean,
val units: UnitSearchResult,
val selectedUnitGroup: UnitGroup?,
val sorting: UnitsListSorting,
) : UnitSelectorUIState()
data class UnitTo(
val query: TextFieldValue,
val unitFrom: AbstractUnit, val unitFrom: AbstractUnit,
val unitTo: AbstractUnit, val unitTo: AbstractUnit,
val query: TextFieldValue, val showFavoritesOnly: Boolean,
val units: Map<UnitGroup, List<AbstractUnit>>, val units: UnitSearchResult,
val favorites: Boolean, val input: String?,
val sorting: UnitsListSorting, val sorting: UnitsListSorting,
val input: String,
val scale: Int, val scale: Int,
val outputFormat: Int, val outputFormat: Int,
val formatterSymbols: FormatterSymbols, val formatterSymbols: FormatterSymbols,
val currencyRateUpdateState: CurrencyRateUpdateState, ) : UnitSelectorUIState()
) : RightSideUIState() }
internal sealed class UnitSearchResult {
data object Empty : UnitSearchResult()
data object Loading : UnitSearchResult()
data class Success(
val units: Map<UnitGroup, List<AbstractUnit>>
) : UnitSearchResult()
} }

View File

@ -0,0 +1,149 @@
/*
* Unitto is a calculator for Android
* Copyright (c) 2024 Elshan Agaev
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.sadellie.unitto.feature.converter
import androidx.compose.ui.text.input.TextFieldValue
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.sadellie.unitto.core.ui.common.textfield.AllFormatterSymbols
import com.sadellie.unitto.data.common.stateIn
import com.sadellie.unitto.data.model.UnitGroup
import com.sadellie.unitto.data.model.repository.UnitsRepository
import com.sadellie.unitto.data.model.repository.UserPreferencesRepository
import com.sadellie.unitto.data.model.unit.AbstractUnit
import com.sadellie.unitto.feature.converter.navigation.inputArg
import com.sadellie.unitto.feature.converter.navigation.unitFromIdArg
import com.sadellie.unitto.feature.converter.navigation.unitGroupArg
import com.sadellie.unitto.feature.converter.navigation.unitToIdArg
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import javax.inject.Inject
@HiltViewModel
internal class UnitSelectorViewModel @Inject constructor(
private val userPrefsRepository: UserPreferencesRepository,
private val unitsRepo: UnitsRepository,
savedStateHandle: SavedStateHandle
) : ViewModel() {
private val _query = MutableStateFlow(TextFieldValue())
private val _searchResults = MutableStateFlow<UnitSearchResult>(UnitSearchResult.Loading)
private val _selectedUnitGroup = MutableStateFlow(savedStateHandle.get<UnitGroup>(unitGroupArg))
private val _unitFromId = savedStateHandle.get<String>(unitFromIdArg)
private val _unitToId = savedStateHandle.get<String>(unitToIdArg)
private val _input = savedStateHandle.get<String>(inputArg)
val unitFromUIState: StateFlow<UnitSelectorUIState> = combine(
_query,
_searchResults,
_selectedUnitGroup,
userPrefsRepository.converterPrefs,
) { query, searchResults, selectedUnitGroup, prefs ->
if (_unitFromId.isNullOrEmpty()) return@combine UnitSelectorUIState.Loading
return@combine UnitSelectorUIState.UnitFrom(
query = query,
unitFrom = unitsRepo.getById(_unitFromId),
shownUnitGroups = prefs.shownUnitGroups,
showFavoritesOnly = prefs.unitConverterFavoritesOnly,
units = searchResults,
selectedUnitGroup = selectedUnitGroup,
sorting = prefs.unitConverterSorting,
)
}
.mapLatest { ui ->
if (ui is UnitSelectorUIState.UnitFrom) {
_searchResults.update {
val result = unitsRepo.filterUnits(
query = ui.query.text,
unitGroup = ui.selectedUnitGroup,
favoritesOnly = ui.showFavoritesOnly,
hideBrokenUnits = false,
sorting = ui.sorting,
shownUnitGroups = ui.shownUnitGroups
)
if (result.isEmpty()) UnitSearchResult.Empty else UnitSearchResult.Success(result)
}
}
ui
}
.stateIn(viewModelScope, UnitSelectorUIState.Loading)
val unitToUIState: StateFlow<UnitSelectorUIState> = combine(
_query,
_searchResults,
userPrefsRepository.converterPrefs,
unitsRepo.units,
) { query, searchResults, prefs, _ ->
if (_unitFromId.isNullOrEmpty()) return@combine UnitSelectorUIState.Loading
if (_unitToId.isNullOrEmpty()) return@combine UnitSelectorUIState.Loading
UnitSelectorUIState.UnitTo(
query = query,
unitFrom = unitsRepo.getById(_unitFromId),
unitTo = unitsRepo.getById(_unitToId),
showFavoritesOnly = prefs.unitConverterFavoritesOnly,
units = searchResults,
input = _input,
sorting = prefs.unitConverterSorting,
scale = prefs.precision,
outputFormat = prefs.outputFormat,
formatterSymbols = AllFormatterSymbols.getById(prefs.separator),
)
}
.mapLatest { ui ->
if (ui is UnitSelectorUIState.UnitTo) {
_searchResults.update {
if (ui.unitFrom.group == UnitGroup.CURRENCY) unitsRepo.updateRates(ui.unitFrom)
val result = unitsRepo.filterUnits(
query = ui.query.text,
unitGroup = ui.unitFrom.group,
favoritesOnly = ui.showFavoritesOnly,
hideBrokenUnits = true,
sorting = ui.sorting,
)
if (result.isEmpty()) UnitSearchResult.Empty else UnitSearchResult.Success(result)
}
}
ui
}
.stateIn(viewModelScope, UnitSelectorUIState.Loading)
fun updateSelectorQuery(value: TextFieldValue) = _query.update { value }
fun updateShowFavoritesOnly(value: Boolean) = viewModelScope.launch {
userPrefsRepository.updateUnitConverterFavoritesOnly(value)
}
fun updateSelectedUnitGroup(value: UnitGroup?) = _selectedUnitGroup.update { value }
fun favoriteUnit(unit: AbstractUnit) = viewModelScope.launch(Dispatchers.IO) {
unitsRepo.favorite(unit)
}
}

View File

@ -47,31 +47,31 @@ import com.sadellie.unitto.feature.converter.components.UnitsList
import java.math.BigDecimal import java.math.BigDecimal
@Composable @Composable
internal fun RightSideRoute( internal fun UnitToSelectorRoute(
viewModel: ConverterViewModel, unitSelectorViewModel: UnitSelectorViewModel,
converterViewModel: ConverterViewModel,
navigateUp: () -> Unit, navigateUp: () -> Unit,
navigateToUnitGroups: () -> Unit, navigateToUnitGroups: () -> Unit,
) { ) {
when ( when (
val uiState = viewModel.rightSideUIState.collectAsStateWithLifecycle().value val uiState = unitSelectorViewModel.unitToUIState.collectAsStateWithLifecycle().value
) { ) {
is RightSideUIState.Loading -> EmptyScreen() is UnitSelectorUIState.UnitTo -> UnitToSelectorScreen(
is RightSideUIState.Ready -> uiState = uiState,
RightSideScreen( onQueryChange = unitSelectorViewModel::updateSelectorQuery,
uiState = uiState, toggleFavoritesOnly = unitSelectorViewModel::updateShowFavoritesOnly,
onQueryChange = viewModel::queryChangeRight, updateUnitTo = converterViewModel::updateUnitTo,
toggleFavoritesOnly = viewModel::favoritesOnlyChange, favoriteUnit = unitSelectorViewModel::favoriteUnit,
updateUnitTo = viewModel::updateUnitTo, navigateUp = navigateUp,
favoriteUnit = viewModel::favoriteUnit, navigateToUnitGroups = navigateToUnitGroups,
navigateUp = navigateUp, )
navigateToUnitGroups = navigateToUnitGroups, else -> EmptyScreen()
)
} }
} }
@Composable @Composable
private fun RightSideScreen( private fun UnitToSelectorScreen(
uiState: RightSideUIState.Ready, uiState: UnitSelectorUIState.UnitTo,
onQueryChange: (TextFieldValue) -> Unit, onQueryChange: (TextFieldValue) -> Unit,
toggleFavoritesOnly: (Boolean) -> Unit, toggleFavoritesOnly: (Boolean) -> Unit,
updateUnitTo: (AbstractUnit) -> Unit, updateUnitTo: (AbstractUnit) -> Unit,
@ -89,8 +89,8 @@ private fun RightSideScreen(
onQueryChange = onQueryChange, onQueryChange = onQueryChange,
navigateUp = navigateUp, navigateUp = navigateUp,
trailingIcon = { trailingIcon = {
FavoritesButton(uiState.favorites) { FavoritesButton(uiState.showFavoritesOnly) {
toggleFavoritesOnly(!uiState.favorites) toggleFavoritesOnly(!uiState.showFavoritesOnly)
} }
}, },
scrollBehavior = scrollBehavior scrollBehavior = scrollBehavior
@ -100,7 +100,7 @@ private fun RightSideScreen(
val resources = LocalContext.current.resources val resources = LocalContext.current.resources
UnitsList( UnitsList(
modifier = Modifier.padding(paddingValues), modifier = Modifier.padding(paddingValues),
groupedUnits = uiState.units, searchResult = uiState.units,
navigateToUnitGroups = navigateToUnitGroups, navigateToUnitGroups = navigateToUnitGroups,
currentUnitId = uiState.unitTo.id, currentUnitId = uiState.unitTo.id,
supportLabel = { supportLabel = {
@ -112,7 +112,6 @@ private fun RightSideScreen(
scale = uiState.scale, scale = uiState.scale,
outputFormat = uiState.outputFormat, outputFormat = uiState.outputFormat,
formatterSymbols = uiState.formatterSymbols, formatterSymbols = uiState.formatterSymbols,
readyCurrencies = uiState.currencyRateUpdateState is CurrencyRateUpdateState.Ready,
) )
}, },
onClick = { onClick = {
@ -128,15 +127,13 @@ private fun RightSideScreen(
private fun formatUnitToSupportLabel( private fun formatUnitToSupportLabel(
unitFrom: AbstractUnit?, unitFrom: AbstractUnit?,
unitTo: AbstractUnit?, unitTo: AbstractUnit?,
input: String, input: String?,
shortName: String, shortName: String,
scale: Int, scale: Int,
outputFormat: Int, outputFormat: Int,
formatterSymbols: FormatterSymbols, formatterSymbols: FormatterSymbols,
readyCurrencies: Boolean,
): String { ): String {
if ((unitFrom?.group == UnitGroup.CURRENCY) and !readyCurrencies) return shortName if (input.isNullOrEmpty()) return shortName
if (input.isEmpty()) return shortName
try { try {
if ((unitFrom is DefaultUnit) and (unitTo is DefaultUnit)) { if ((unitFrom is DefaultUnit) and (unitTo is DefaultUnit)) {
@ -168,7 +165,7 @@ private fun formatUnitToSupportLabel(
@Preview @Preview
@Composable @Composable
private fun RightSideScreenPreview() { private fun UnitToSelectorPreview() {
val units: Map<UnitGroup, List<AbstractUnit>> = mapOf( val units: Map<UnitGroup, List<AbstractUnit>> = mapOf(
UnitGroup.LENGTH to listOf( UnitGroup.LENGTH to listOf(
NormalUnit(UnitID.meter, BigDecimal.valueOf(1.0E+18), UnitGroup.LENGTH, R.string.unit_meter, R.string.unit_meter_short), NormalUnit(UnitID.meter, BigDecimal.valueOf(1.0E+18), UnitGroup.LENGTH, R.string.unit_meter, R.string.unit_meter_short),
@ -181,19 +178,18 @@ private fun RightSideScreenPreview() {
) )
) )
RightSideScreen( UnitToSelectorScreen(
uiState = RightSideUIState.Ready( uiState = UnitSelectorUIState.UnitTo(
unitFrom = units.values.first().first(), unitFrom = units.values.first().first(),
units = units, unitTo = units.values.first().first(),
query = TextFieldValue(), query = TextFieldValue("test"),
favorites = false, units = UnitSearchResult.Success(units),
showFavoritesOnly = false,
sorting = UnitsListSorting.USAGE, sorting = UnitsListSorting.USAGE,
unitTo = units.values.first()[1],
input = "100", input = "100",
scale = 3, scale = 3,
outputFormat = OutputFormat.PLAIN, outputFormat = OutputFormat.PLAIN,
formatterSymbols = FormatterSymbols.Spaces, formatterSymbols = FormatterSymbols.Spaces,
currencyRateUpdateState = CurrencyRateUpdateState.Nothing
), ),
onQueryChange = {}, onQueryChange = {},
toggleFavoritesOnly = {}, toggleFavoritesOnly = {},

View File

@ -33,12 +33,13 @@ import com.sadellie.unitto.data.converter.UnitID
import com.sadellie.unitto.data.model.UnitGroup import com.sadellie.unitto.data.model.UnitGroup
import com.sadellie.unitto.data.model.unit.AbstractUnit import com.sadellie.unitto.data.model.unit.AbstractUnit
import com.sadellie.unitto.data.model.unit.NormalUnit import com.sadellie.unitto.data.model.unit.NormalUnit
import com.sadellie.unitto.feature.converter.UnitSearchResult
import java.math.BigDecimal import java.math.BigDecimal
@Composable @Composable
internal fun UnitsList( internal fun UnitsList(
modifier: Modifier, modifier: Modifier,
groupedUnits: Map<UnitGroup, List<AbstractUnit>>, searchResult: UnitSearchResult,
navigateToUnitGroups: () -> Unit, navigateToUnitGroups: () -> Unit,
currentUnitId: String, currentUnitId: String,
supportLabel: (AbstractUnit) -> String, supportLabel: (AbstractUnit) -> String,
@ -47,14 +48,14 @@ internal fun UnitsList(
) { ) {
Crossfade( Crossfade(
modifier = modifier, modifier = modifier,
targetState = groupedUnits.isNotEmpty(), targetState = searchResult,
label = "Units list" label = "Units list"
) { hasUnits -> ) { result ->
when (hasUnits) { when (result) {
true -> LazyColumn( is UnitSearchResult.Success -> LazyColumn(
modifier = Modifier.fillMaxSize() modifier = Modifier.fillMaxSize()
) { ) {
groupedUnits.forEach { (group, units) -> result.units.forEach { (group, units) ->
item(group.name) { item(group.name) {
UnitGroupHeader(Modifier.animateItemPlacement(), group) UnitGroupHeader(Modifier.animateItemPlacement(), group)
} }
@ -73,11 +74,13 @@ internal fun UnitsList(
} }
} }
false -> SearchPlaceholder( UnitSearchResult.Empty -> SearchPlaceholder(
onButtonClick = navigateToUnitGroups, onButtonClick = navigateToUnitGroups,
supportText = stringResource(R.string.converter_no_results_support), supportText = stringResource(R.string.converter_no_results_support),
buttonLabel = stringResource(R.string.open_settings_label) buttonLabel = stringResource(R.string.open_settings_label)
) )
UnitSearchResult.Loading -> Unit
} }
} }
} }
@ -100,7 +103,7 @@ private fun PreviewUnitsList() {
UnitsList( UnitsList(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
groupedUnits = groupedUnits, searchResult = UnitSearchResult.Success(units = groupedUnits),
navigateToUnitGroups = {}, navigateToUnitGroups = {},
currentUnitId = UnitID.mile, currentUnitId = UnitID.mile,
supportLabel = { resources.getString(it.shortName) }, supportLabel = { resources.getString(it.shortName) },

View File

@ -22,19 +22,42 @@ import androidx.compose.runtime.remember
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import androidx.navigation.NavGraphBuilder import androidx.navigation.NavGraphBuilder
import androidx.navigation.NavHostController import androidx.navigation.NavHostController
import androidx.navigation.NavType
import androidx.navigation.navArgument
import androidx.navigation.navDeepLink import androidx.navigation.navDeepLink
import com.sadellie.unitto.core.ui.model.DrawerItem import com.sadellie.unitto.core.ui.model.DrawerItem
import com.sadellie.unitto.core.ui.unittoComposable import com.sadellie.unitto.core.ui.unittoComposable
import com.sadellie.unitto.core.ui.unittoNavigation import com.sadellie.unitto.core.ui.unittoNavigation
import com.sadellie.unitto.data.model.UnitGroup
import com.sadellie.unitto.feature.converter.ConverterRoute import com.sadellie.unitto.feature.converter.ConverterRoute
import com.sadellie.unitto.feature.converter.ConverterViewModel import com.sadellie.unitto.feature.converter.ConverterViewModel
import com.sadellie.unitto.feature.converter.LeftSideRoute import com.sadellie.unitto.feature.converter.CurrencyRateUpdateState
import com.sadellie.unitto.feature.converter.RightSideRoute import com.sadellie.unitto.feature.converter.UnitConverterUIState
import com.sadellie.unitto.feature.converter.UnitFromSelectorRoute
import com.sadellie.unitto.feature.converter.UnitToSelectorRoute
private val graph = DrawerItem.Converter.graph private val graph = DrawerItem.Converter.graph
private val start = DrawerItem.Converter.start private val start = DrawerItem.Converter.start
private const val LEFT = "left"
private const val RIGHT = "right" private const val UNIT_FROM = "unitFromSelector"
private const val UNIT_TO = "unitToSelector"
internal const val unitGroupArg = "unitGroupArg"
internal const val unitFromIdArg = "unitFromId"
internal const val unitToIdArg = "unitToIdArg"
internal const val inputArg = "inputArg"
private const val UNIT_FROM_ROUTE = "$UNIT_FROM/{$unitFromIdArg}/{$unitGroupArg}"
private const val UNIT_TO_ROUTE = "$UNIT_TO/{$unitFromIdArg}/{$unitToIdArg}/{$inputArg}"
private fun NavHostController.navigateLeft(
unitFromId: String,
unitGroup: UnitGroup,
) = navigate("$UNIT_FROM/$unitFromId/$unitGroup")
private fun NavHostController.navigateRight(
unitFromId: String,
unitToId: String,
input: String?,
) = navigate("$UNIT_TO/$unitFromId/$unitToId/$input")
fun NavGraphBuilder.converterGraph( fun NavGraphBuilder.converterGraph(
openDrawer: () -> Unit, openDrawer: () -> Unit,
@ -58,36 +81,107 @@ fun NavGraphBuilder.converterGraph(
ConverterRoute( ConverterRoute(
viewModel = parentViewModel, viewModel = parentViewModel,
navigateToLeftScreen = { navController.navigate(LEFT) }, // Navigation logic is here, but should actually be in ConverterScreen
navigateToRightScreen = { navController.navigate(RIGHT) }, navigateToLeftScreen = { uiState: UnitConverterUIState ->
when (uiState) {
is UnitConverterUIState.Default -> navController
.navigateLeft(uiState.unitFrom.id, uiState.unitFrom.group)
is UnitConverterUIState.NumberBase -> navController
.navigateLeft(uiState.unitFrom.id, uiState.unitFrom.group)
else -> Unit
}
},
navigateToRightScreen = { uiState: UnitConverterUIState ->
when (uiState) {
is UnitConverterUIState.Default -> {
// Don't allow converting if still loading currencies
val convertingCurrencies = uiState.unitFrom.group == UnitGroup.CURRENCY
val currenciesReady =
uiState.currencyRateUpdateState is CurrencyRateUpdateState.Ready
val input: String? = if (convertingCurrencies and !currenciesReady) {
null
} else {
(uiState.calculation?.toPlainString() ?: uiState.input1.text)
.ifEmpty { null }
}
navController.navigateRight(
uiState.unitFrom.id,
uiState.unitTo.id,
input
)
}
is UnitConverterUIState.NumberBase -> {
val input = uiState.input.text.ifEmpty { null }
navController.navigateRight(
uiState.unitFrom.id,
uiState.unitTo.id,
input
)
}
UnitConverterUIState.Loading -> Unit
}
},
navigateToSettings = navigateToSettings, navigateToSettings = navigateToSettings,
navigateToMenu = openDrawer navigateToMenu = openDrawer
) )
} }
unittoComposable(LEFT) { backStackEntry -> unittoComposable(
route = UNIT_FROM_ROUTE,
arguments = listOf(
navArgument(unitFromIdArg) {
type = NavType.StringType
},
navArgument(unitGroupArg) {
type = NavType.EnumType(UnitGroup::class.java)
},
)
) { backStackEntry ->
val parentEntry = remember(backStackEntry) { val parentEntry = remember(backStackEntry) {
navController.getBackStackEntry(graph) navController.getBackStackEntry(graph)
} }
val parentViewModel = hiltViewModel<ConverterViewModel>(parentEntry) val parentViewModel = hiltViewModel<ConverterViewModel>(parentEntry)
LeftSideRoute( UnitFromSelectorRoute(
viewModel = parentViewModel, unitSelectorViewModel = hiltViewModel(),
converterViewModel = parentViewModel,
navigateUp = navController::navigateUp, navigateUp = navController::navigateUp,
navigateToUnitGroups = navigateToUnitGroups navigateToUnitGroups = navigateToUnitGroups
) )
} }
unittoComposable(RIGHT) { backStackEntry -> unittoComposable(
route = UNIT_TO_ROUTE,
arguments = listOf(
navArgument(unitFromIdArg) {
type = NavType.StringType
},
navArgument(unitToIdArg) {
type = NavType.StringType
},
navArgument(inputArg) {
type = NavType.StringType
nullable = true
defaultValue = null
},
)
) { backStackEntry ->
val parentEntry = remember(backStackEntry) { val parentEntry = remember(backStackEntry) {
navController.getBackStackEntry(graph) navController.getBackStackEntry(graph)
} }
val parentViewModel = hiltViewModel<ConverterViewModel>(parentEntry) val parentViewModel = hiltViewModel<ConverterViewModel>(parentEntry)
RightSideRoute( UnitToSelectorRoute(
viewModel = parentViewModel, unitSelectorViewModel = hiltViewModel(),
converterViewModel = parentViewModel,
navigateUp = navController::navigateUp, navigateUp = navController::navigateUp,
navigateToUnitGroups = navigateToUnitGroups navigateToUnitGroups = navigateToUnitGroups
) )