Feet and inches input and output

closes #145
This commit is contained in:
Sad Ellie 2024-01-04 18:42:39 +03:00
parent 839954ce85
commit 5b7169d396
3 changed files with 264 additions and 82 deletions

View File

@ -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,11 +319,56 @@ private fun Default(
)
}
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 = textFieldModifier,
minRatio = 0.7f,
placeholder = Token.Digit._0,
value = uiState.input,
value = uiState.input1,
onCursorChange = onCursorChange,
pasteCallback = processInput,
cutCallback = deleteDigit,
@ -334,6 +391,7 @@ private fun Default(
)
}
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 = {}
)

View File

@ -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") }

View File

@ -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<BigDecimal?>(null)
private val _result = MutableStateFlow<ConverterResult>(ConverterResult.Loading)
private val _unitFrom = MutableStateFlow<AbstractUnit?>(null)
@ -83,18 +86,20 @@ internal class ConverterViewModel @Inject constructor(
private var _loadCurrenciesJob: Job? = null
val converterUiState: StateFlow<UnitConverterUIState> = 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,12 +139,14 @@ internal class ConverterViewModel @Inject constructor(
is CurrencyRateUpdateState.Ready, is CurrencyRateUpdateState.Nothing -> {}
}
try {
when (ui) {
is UnitConverterUIState.Default -> {
convertDefault(
unitFrom = ui.unitFrom,
unitTo = ui.unitTo,
input = ui.input,
input1 = ui.input1,
input2 = ui.input2,
formatTime = ui.formatTime
)
}
@ -152,6 +159,11 @@ internal class ConverterViewModel @Inject constructor(
}
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 {
/**
* 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[converterInputKey] = newValue.text
savedStateHandle[converterInputKey2] = newValue.text
newValue
}
} else {
_input1.update {
val newValue = it.addTokens(tokens)
savedStateHandle[converterInputKey1] = newValue.text
newValue
}
}
}
fun addBracket() = _input.update {
fun addBracket() {
if (_focusedOnInput2.value) {
_input2.update {
val newValue = it.addBracket()
savedStateHandle[converterInputKey] = newValue.text
savedStateHandle[converterInputKey2] = newValue.text
newValue
}
} else {
_input1.update {
val newValue = it.addBracket()
savedStateHandle[converterInputKey1] = newValue.text
newValue
}
}
}
fun deleteTokens() = _input.update {
fun deleteTokens() {
if (_focusedOnInput2.value) {
_input2.update {
val newValue = it.deleteTokens()
savedStateHandle[converterInputKey] = newValue.text
savedStateHandle[converterInputKey2] = newValue.text
newValue
}
} else {
_input1.update {
val newValue = it.deleteTokens()
savedStateHandle[converterInputKey1] = newValue.text
newValue
}
}
}
fun clearInput() = _input.update {
savedStateHandle[converterInputKey] = ""
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)) }
val calculated2 = try {
Expression(input2.text.ifEmpty { Token.Digit._0 }).calculate()
} catch (e: ExpressionException.DivideByZero) {
_calculation.update { null }
return@launch
} catch (e: Exception) {
return@launch
}
val conversion = unitFrom.convert(unitTo, calculated)
// Update calculation
_calculation.update { if (input1.text.isExpression()) calculated1 else null }
_result.update { ConverterResult.Default(conversion) }
} catch (e: Exception) {
_result.update { ConverterResult.Default(BigDecimal.ZERO) }
// 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)
}
}
}