diff --git a/feature/converter/src/main/java/com/sadellie/unitto/feature/converter/ConverterScreen.kt b/feature/converter/src/main/java/com/sadellie/unitto/feature/converter/ConverterScreen.kt index a5252bdf..c586d30a 100644 --- a/feature/converter/src/main/java/com/sadellie/unitto/feature/converter/ConverterScreen.kt +++ b/feature/converter/src/main/java/com/sadellie/unitto/feature/converter/ConverterScreen.kt @@ -31,6 +31,8 @@ import androidx.compose.animation.fadeOut import androidx.compose.animation.shrinkVertically import androidx.compose.animation.togetherWith import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -46,6 +48,7 @@ import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.VerticalDivider import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.derivedStateOf @@ -57,6 +60,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.rotate +import androidx.compose.ui.focus.onFocusEvent import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource @@ -64,6 +68,7 @@ import androidx.compose.ui.text.TextRange import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.sadellie.unitto.core.base.OutputFormat @@ -81,6 +86,7 @@ import com.sadellie.unitto.core.ui.common.textfield.FormatterSymbols import com.sadellie.unitto.core.ui.common.textfield.UnformattedTextField import com.sadellie.unitto.core.ui.datetime.formatDateWeekDayMonthYear import com.sadellie.unitto.data.common.format +import com.sadellie.unitto.data.converter.MyUnitIDS import com.sadellie.unitto.data.model.UnitGroup import com.sadellie.unitto.data.model.unit.AbstractUnit import com.sadellie.unitto.feature.converter.components.DefaultKeyboard @@ -109,6 +115,7 @@ internal fun ConverterRoute( deleteDigit = viewModel::deleteTokens, clearInput = viewModel::clearInput, onCursorChange = viewModel::onCursorChange, + onFocusOnInput2 = viewModel::updateFocused, onErrorClick = viewModel::updateCurrencyRates, addBracket = viewModel::addBracket ) @@ -126,6 +133,7 @@ private fun ConverterScreen( deleteDigit: () -> Unit, clearInput: () -> Unit, onCursorChange: (TextRange) -> Unit, + onFocusOnInput2: (Boolean) -> Unit, onErrorClick: (AbstractUnit) -> Unit, addBracket: () -> Unit, ) { @@ -160,6 +168,7 @@ private fun ConverterScreen( modifier = Modifier.padding(it), uiState = uiState, onCursorChange = onCursorChange, + onFocusOnInput2 = onFocusOnInput2, processInput = processInput, deleteDigit = deleteDigit, navigateToLeftScreen = navigateToLeftScreen, @@ -254,6 +263,7 @@ private fun Default( modifier: Modifier, uiState: UnitConverterUIState.Default, onCursorChange: (TextRange) -> Unit, + onFocusOnInput2: (Boolean) -> Unit, processInput: (String) -> Unit, deleteDigit: () -> Unit, navigateToLeftScreen: () -> Unit, @@ -288,7 +298,9 @@ private fun Default( modifier = modifier.fillMaxSize(), content1 = { contentModifier -> ColumnWithConstraints(modifier = contentModifier) { - val textFieldModifier = Modifier.fillMaxWidth().weight(2f) + val textFieldModifier = Modifier + .fillMaxWidth() + .weight(2f) AnimatedVisibility( visible = lastUpdate != null, @@ -307,33 +319,79 @@ private fun Default( ) } - ExpressionTextField( - modifier = textFieldModifier, - minRatio = 0.7f, - placeholder = Token.Digit._0, - value = uiState.input, - onCursorChange = onCursorChange, - pasteCallback = processInput, - cutCallback = deleteDigit, - formatterSymbols = uiState.formatterSymbols, - ) - AnimatedVisibility( - visible = calculation.text.isNotEmpty(), - modifier = Modifier.weight(1f), - enter = expandVertically(clip = false), - exit = shrinkVertically(clip = false) - ) { + if (uiState.unitFrom.id == MyUnitIDS.foot) { + Row( + modifier = textFieldModifier, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .weight(1f) + ) { + ExpressionTextField( + modifier = Modifier.fillMaxWidth().weight(1f), + minRatio = 0.7f, + placeholder = Token.Digit._0, + value = uiState.input1, + onCursorChange = onCursorChange, + pasteCallback = processInput, + cutCallback = deleteDigit, + formatterSymbols = uiState.formatterSymbols, + ) + AnimatedUnitShortName(stringResource(uiState.unitFrom.shortName)) + } + + VerticalDivider() + + Column( + modifier = Modifier + .fillMaxWidth() + .weight(1f) + ) { + ExpressionTextField( + modifier = Modifier.fillMaxWidth().weight(1f) + .onFocusEvent { state -> onFocusOnInput2(state.hasFocus) }, + minRatio = 0.7f, + placeholder = Token.Digit._0, + value = uiState.input2, + onCursorChange = onCursorChange, + pasteCallback = processInput, + cutCallback = deleteDigit, + formatterSymbols = uiState.formatterSymbols, + ) + AnimatedUnitShortName(stringResource(R.string.unit_inch_short)) + } + } + } else { ExpressionTextField( - modifier = Modifier, - value = calculation, - onCursorChange = { calculation = calculation.copy(selection = it) }, - formatterSymbols = uiState.formatterSymbols, + modifier = textFieldModifier, minRatio = 0.7f, - textColor = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f), - readOnly = true + placeholder = Token.Digit._0, + value = uiState.input1, + onCursorChange = onCursorChange, + pasteCallback = processInput, + cutCallback = deleteDigit, + formatterSymbols = uiState.formatterSymbols, ) + AnimatedVisibility( + visible = calculation.text.isNotEmpty(), + modifier = Modifier.weight(1f), + enter = expandVertically(clip = false), + exit = shrinkVertically(clip = false) + ) { + ExpressionTextField( + modifier = Modifier, + value = calculation, + onCursorChange = { calculation = calculation.copy(selection = it) }, + formatterSymbols = uiState.formatterSymbols, + minRatio = 0.7f, + textColor = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f), + readOnly = true + ) + } + AnimatedUnitShortName(stringResource(uiState.unitFrom.shortName)) } - AnimatedUnitShortName(stringResource(uiState.unitFrom.shortName)) ConverterResultTextField( modifier = textFieldModifier, @@ -392,6 +450,7 @@ private fun ConverterResultTextField( is ConverterResult.Default -> result.value.format(scale, outputFormat) is ConverterResult.NumberBase -> result.value.uppercase() is ConverterResult.Time -> result.format(mContext, formatterSymbols) + is ConverterResult.FootInch -> result.format(mContext, scale, outputFormat, formatterSymbols) else -> "" } mutableStateOf(TextFieldValue(value)) @@ -430,7 +489,9 @@ private fun ConverterResultTextField( ) } - is ConverterResult.NumberBase, is ConverterResult.Time -> { + is ConverterResult.NumberBase, + is ConverterResult.Time, + is ConverterResult.FootInch -> { UnformattedTextField( modifier = modifier, value = resultTextField, @@ -527,6 +588,7 @@ private fun PreviewConverterScreen() { deleteDigit = {}, clearInput = {}, onCursorChange = {}, + onFocusOnInput2 = {}, onErrorClick = {}, addBracket = {} ) diff --git a/feature/converter/src/main/java/com/sadellie/unitto/feature/converter/ConverterUIState.kt b/feature/converter/src/main/java/com/sadellie/unitto/feature/converter/ConverterUIState.kt index 772900e3..7d0dcc8c 100644 --- a/feature/converter/src/main/java/com/sadellie/unitto/feature/converter/ConverterUIState.kt +++ b/feature/converter/src/main/java/com/sadellie/unitto/feature/converter/ConverterUIState.kt @@ -24,6 +24,7 @@ import com.sadellie.unitto.core.base.R import com.sadellie.unitto.core.base.Token import com.sadellie.unitto.core.ui.common.textfield.FormatterSymbols import com.sadellie.unitto.core.ui.common.textfield.formatExpression +import com.sadellie.unitto.data.common.format import com.sadellie.unitto.data.common.isEqualTo import com.sadellie.unitto.data.common.isGreaterThan import com.sadellie.unitto.data.common.isLessThan @@ -37,7 +38,8 @@ internal sealed class UnitConverterUIState { data object Loading : UnitConverterUIState() data class Default( - val input: TextFieldValue = TextFieldValue(), + val input1: TextFieldValue, + val input2: TextFieldValue, val calculation: BigDecimal?, val result: ConverterResult, val unitFrom: DefaultUnit, @@ -53,7 +55,7 @@ internal sealed class UnitConverterUIState { ) : UnitConverterUIState() data class NumberBase( - val input: TextFieldValue = TextFieldValue(), + val input: TextFieldValue, val result: ConverterResult, val unitFrom: NumberBaseUnit, val unitTo: NumberBaseUnit, @@ -85,6 +87,11 @@ internal sealed class ConverterResult { val attosecond: BigDecimal, ) : ConverterResult() + data class FootInch( + val foot: BigDecimal, + val inch: BigDecimal, + ) : ConverterResult() + data object Loading : ConverterResult() data object Error : ConverterResult() @@ -128,6 +135,25 @@ internal fun ConverterResult.Time.format(mContext: Context, formatterSymbols: Fo return (if (negative) Token.Operator.minus else "") + result.joinToString(" ").ifEmpty { Token.Digit._0 } } +internal fun ConverterResult.FootInch.format( + mContext: Context, + scale: Int, + outputFormat: Int, + formatterSymbols: FormatterSymbols +): String { + var result = "" + result += foot.format(scale, outputFormat).formatExpression(formatterSymbols) + + if (inch.isGreaterThan(BigDecimal.ZERO)) { + result += mContext.getString(R.string.unit_foot_short) + result += " " + result += inch.format(scale, outputFormat).formatExpression(formatterSymbols) + result += mContext.getString(R.string.unit_inch_short) + } + + return result +} + internal fun formatTime( input: BigDecimal, ): ConverterResult.Time { @@ -202,6 +228,26 @@ internal fun formatTime( ) } +/** + * Creates an object for displaying formatted foot and inch output. Units are passed as objects so + * that changes in basic units don't require modifying the method. Also this method can't access + * units repository directly. + * + * @param input Input in feet. + * @param footUnit Foot unit [DefaultUnit]. + * @param inchUnit Inch unit [DefaultUnit]. + * @return Result where decimal places are converter into inches. + */ +internal fun formatFootInch( + input: BigDecimal, + footUnit: DefaultUnit, + inchUnit: DefaultUnit +): ConverterResult.FootInch { + val (integral, fractional) = input.divideAndRemainder(BigDecimal.ONE) + + return ConverterResult.FootInch(integral, footUnit.convert(inchUnit, fractional)) +} + private val dayBasicUnit by lazy { BigDecimal("86400000000000000000000") } private val hourBasicUnit by lazy { BigDecimal("3600000000000000000000") } private val minuteBasicUnit by lazy { BigDecimal("60000000000000000000") } diff --git a/feature/converter/src/main/java/com/sadellie/unitto/feature/converter/ConverterViewModel.kt b/feature/converter/src/main/java/com/sadellie/unitto/feature/converter/ConverterViewModel.kt index 5595868a..9cee0282 100644 --- a/feature/converter/src/main/java/com/sadellie/unitto/feature/converter/ConverterViewModel.kt +++ b/feature/converter/src/main/java/com/sadellie/unitto/feature/converter/ConverterViewModel.kt @@ -65,8 +65,11 @@ internal class ConverterViewModel @Inject constructor( private val savedStateHandle: SavedStateHandle ) : ViewModel() { - private val converterInputKey = "CONVERTER_INPUT" - private val _input = MutableStateFlow(savedStateHandle.getTextField(converterInputKey)) + private val converterInputKey1 = "CONVERTER_INPUT_1" + private val converterInputKey2 = "CONVERTER_INPUT_2" + private val _input1 = MutableStateFlow(savedStateHandle.getTextField(converterInputKey1)) + private val _input2 = MutableStateFlow(savedStateHandle.getTextField(converterInputKey2)) + private val _focusedOnInput2 = MutableStateFlow(false) private val _calculation = MutableStateFlow(null) private val _result = MutableStateFlow(ConverterResult.Loading) private val _unitFrom = MutableStateFlow(null) @@ -83,18 +86,20 @@ internal class ConverterViewModel @Inject constructor( private var _loadCurrenciesJob: Job? = null val converterUiState: StateFlow = combine( - _input, + _input1, + _input2, _calculation, _result, _unitFrom, _unitTo, userPrefsRepository.converterPrefs, _currenciesState - ) { input, calculation, result, unitFrom, unitTo, prefs, currenciesState -> + ) { input1, input2, calculation, result, unitFrom, unitTo, prefs, currenciesState -> return@combine when { (unitFrom is DefaultUnit) and (unitTo is DefaultUnit) -> { UnitConverterUIState.Default( - input = input, + input1 = input1, + input2 = input2, calculation = calculation, result = result, unitFrom = unitFrom as DefaultUnit, @@ -111,7 +116,7 @@ internal class ConverterViewModel @Inject constructor( } (unitFrom is NumberBaseUnit) and (unitTo is NumberBaseUnit) -> { UnitConverterUIState.NumberBase( - input = input, + input = input1, result = result, unitFrom = unitFrom as NumberBaseUnit, unitTo = unitTo as NumberBaseUnit, @@ -134,24 +139,31 @@ internal class ConverterViewModel @Inject constructor( is CurrencyRateUpdateState.Ready, is CurrencyRateUpdateState.Nothing -> {} } - when (ui) { - is UnitConverterUIState.Default -> { - convertDefault( - unitFrom = ui.unitFrom, - unitTo = ui.unitTo, - input = ui.input, - formatTime = ui.formatTime - ) + try { + when (ui) { + is UnitConverterUIState.Default -> { + convertDefault( + unitFrom = ui.unitFrom, + unitTo = ui.unitTo, + input1 = ui.input1, + input2 = ui.input2, + formatTime = ui.formatTime + ) + } + is UnitConverterUIState.NumberBase -> { + convertNumberBase( + unitFrom = ui.unitFrom, + unitTo = ui.unitTo, + input = ui.input + ) + } + is UnitConverterUIState.Loading -> {} } - is UnitConverterUIState.NumberBase -> { - convertNumberBase( - unitFrom = ui.unitFrom, - unitTo = ui.unitTo, - input = ui.input - ) - } - is UnitConverterUIState.Loading -> {} + } catch (e: Exception) { + _result.update { ConverterResult.Default(BigDecimal.ZERO) } } + + ui } .stateIn(viewModelScope, UnitConverterUIState.Loading) @@ -194,7 +206,7 @@ internal class ConverterViewModel @Inject constructor( val rightSideUIState = combine( _unitFrom, _unitTo, - _input, + _input1, _calculation, _rightQuery, _rightUnits, @@ -251,30 +263,73 @@ internal class ConverterViewModel @Inject constructor( } } - fun addTokens(tokens: String) = _input.update { - val newValue = it.addTokens(tokens) - savedStateHandle[converterInputKey] = newValue.text - newValue + /** + * Change currently focused text field. For feet and inches input + * + * @param focusOnInput2 `true` if focus is on inches input. `false`if focus on feet input. + */ + fun updateFocused(focusOnInput2: Boolean) = _focusedOnInput2.update { focusOnInput2 } + + fun addTokens(tokens: String) { + if (_focusedOnInput2.value) { + _input2.update { + val newValue = it.addTokens(tokens) + savedStateHandle[converterInputKey2] = newValue.text + newValue + } + } else { + _input1.update { + val newValue = it.addTokens(tokens) + savedStateHandle[converterInputKey1] = newValue.text + newValue + } + } } - fun addBracket() = _input.update { - val newValue = it.addBracket() - savedStateHandle[converterInputKey] = newValue.text - newValue + fun addBracket() { + if (_focusedOnInput2.value) { + _input2.update { + val newValue = it.addBracket() + savedStateHandle[converterInputKey2] = newValue.text + newValue + } + } else { + _input1.update { + val newValue = it.addBracket() + savedStateHandle[converterInputKey1] = newValue.text + newValue + } + } } - fun deleteTokens() = _input.update { - val newValue = it.deleteTokens() - savedStateHandle[converterInputKey] = newValue.text - newValue + fun deleteTokens() { + if (_focusedOnInput2.value) { + _input2.update { + val newValue = it.deleteTokens() + savedStateHandle[converterInputKey2] = newValue.text + newValue + } + } else { + _input1.update { + val newValue = it.deleteTokens() + savedStateHandle[converterInputKey1] = newValue.text + newValue + } + } } - fun clearInput() = _input.update { - savedStateHandle[converterInputKey] = "" - TextFieldValue() + fun clearInput() { + _input1.update { + savedStateHandle[converterInputKey1] = "" + TextFieldValue() + } + _input2.update { + savedStateHandle[converterInputKey2] = "" + TextFieldValue() + } } - fun onCursorChange(selection: TextRange) = _input.update { it.copy(selection = selection) } + fun onCursorChange(selection: TextRange) = _input1.update { it.copy(selection = selection) } fun updateCurrencyRates(unit: AbstractUnit) { _loadCurrenciesJob = viewModelScope.launch(Dispatchers.IO) { @@ -387,31 +442,50 @@ internal class ConverterViewModel @Inject constructor( private fun convertDefault( unitFrom: DefaultUnit, unitTo: DefaultUnit, - input: TextFieldValue, + input1: TextFieldValue, + input2: TextFieldValue, formatTime: Boolean, ) = viewModelScope.launch(Dispatchers.Default) { - val calculated = try { - Expression(input.text.ifEmpty { Token.Digit._0 }).calculate() + val footInchInput = unitFrom.id == MyUnitIDS.foot + + if (footInchInput) { _calculation.update { null } } + + // Calculate + val calculated1 = try { + Expression(input1.text.ifEmpty { Token.Digit._0 }).calculate() } catch (e: ExpressionException.DivideByZero) { _calculation.update { null } return@launch } catch (e: Exception) { return@launch } - _calculation.update { if (input.text.isExpression()) calculated else null } - - try { - if ((unitFrom.group == UnitGroup.TIME) and (formatTime)) { - _result.update { formatTime(calculated.multiply(unitFrom.basicUnit)) } - - return@launch - } - - val conversion = unitFrom.convert(unitTo, calculated) - - _result.update { ConverterResult.Default(conversion) } + val calculated2 = try { + Expression(input2.text.ifEmpty { Token.Digit._0 }).calculate() + } catch (e: ExpressionException.DivideByZero) { + _calculation.update { null } + return@launch } catch (e: Exception) { - _result.update { ConverterResult.Default(BigDecimal.ZERO) } + return@launch + } + + // Update calculation + _calculation.update { if (input1.text.isExpression()) calculated1 else null } + + // Convert + var conversion = unitFrom.convert(unitTo, calculated1) + if (footInchInput) { + // Converted from second text field too + val inches = unitsRepo.getById(MyUnitIDS.inch) as DefaultUnit + conversion += inches.convert(unitTo, calculated2) + } + + // Update result + _result.update { + when { + (unitFrom.group == UnitGroup.TIME) and (formatTime) -> formatTime(calculated1.multiply(unitFrom.basicUnit)) + unitTo.id == MyUnitIDS.foot -> formatFootInch(conversion, unitTo, unitsRepo.getById(MyUnitIDS.inch) as DefaultUnit) + else -> ConverterResult.Default(conversion) + } } }