diff --git a/core/base/src/main/res/values/strings.xml b/core/base/src/main/res/values/strings.xml index a3c02660..342fa2c7 100644 --- a/core/base/src/main/res/values/strings.xml +++ b/core/base/src/main/res/values/strings.xml @@ -10,6 +10,9 @@ Imperial Metric Normal weight + + + Normal weight for your height Obese (Class 1) Obese (Class 2) Obese (Class 3) diff --git a/core/ui/src/main/java/com/sadellie/unitto/core/ui/LocalWindowSize.kt b/core/ui/src/main/java/com/sadellie/unitto/core/ui/LocalWindowSize.kt index 06200d62..8a8e2b4e 100644 --- a/core/ui/src/main/java/com/sadellie/unitto/core/ui/LocalWindowSize.kt +++ b/core/ui/src/main/java/com/sadellie/unitto/core/ui/LocalWindowSize.kt @@ -37,7 +37,7 @@ import androidx.window.layout.WindowMetricsCalculator val LocalWindowSize: ProvidableCompositionLocal = compositionLocalOf { WindowSizeClass.calculateFromSize( - size = Size.Unspecified, + size = Size.Zero, density = defaultDensity ) } diff --git a/feature/bodymass/src/main/java/com/sadellie/unitto/feature/bodymass/BodyMassScreen.kt b/feature/bodymass/src/main/java/com/sadellie/unitto/feature/bodymass/BodyMassScreen.kt index c50c79d6..e797a646 100644 --- a/feature/bodymass/src/main/java/com/sadellie/unitto/feature/bodymass/BodyMassScreen.kt +++ b/feature/bodymass/src/main/java/com/sadellie/unitto/feature/bodymass/BodyMassScreen.kt @@ -18,7 +18,6 @@ package com.sadellie.unitto.feature.bodymass -import androidx.annotation.StringRes import androidx.compose.animation.AnimatedContent import androidx.compose.animation.Crossfade import androidx.compose.animation.SizeTransform @@ -32,33 +31,25 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.text.KeyboardActions -import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.ElevatedButton import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.Immutable -import androidx.compose.runtime.Stable import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.input.ImeAction -import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.TextFieldValue -import androidx.compose.ui.text.input.VisualTransformation +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 import com.sadellie.unitto.core.base.R -import com.sadellie.unitto.core.base.Token import com.sadellie.unitto.core.ui.common.MenuButton import com.sadellie.unitto.core.ui.common.SegmentedButton import com.sadellie.unitto.core.ui.common.SegmentedButtonsRow @@ -66,10 +57,11 @@ import com.sadellie.unitto.core.ui.common.SettingsButton import com.sadellie.unitto.core.ui.common.UnittoEmptyScreen import com.sadellie.unitto.core.ui.common.UnittoScreenWithTopBar import com.sadellie.unitto.core.ui.common.textfield.ExpressionTransformer -import com.sadellie.unitto.core.ui.common.textfield.formatExpression -import com.sadellie.unitto.data.common.format +import com.sadellie.unitto.core.ui.common.textfield.FormatterSymbols +import com.sadellie.unitto.core.ui.openLink import com.sadellie.unitto.data.common.isEqualTo -import com.sadellie.unitto.data.common.isLessThan +import com.sadellie.unitto.feature.bodymass.components.BodyMassResult +import com.sadellie.unitto.feature.bodymass.components.BodyMassTextField import java.math.BigDecimal @Composable @@ -106,15 +98,10 @@ private fun BodyMassScreen( val expressionTransformer = remember(uiState.formatterSymbols) { ExpressionTransformer(uiState.formatterSymbols) } - val weightLabel = remember(uiState.isMetric) { - val s1 = mContext.resources.getString(R.string.body_mass_weight) - val s2 = if (uiState.isMetric) { - mContext.resources.getString(R.string.unit_kilogram_short) - } else { - mContext.resources.getString(R.string.unit_pound_short) - } - - "$s1, $s2" + val weightShortLabel = remember(uiState.isMetric) { + mContext.resources.getString( + if (uiState.isMetric) R.string.unit_kilogram_short else R.string.unit_pound_short + ) } UnittoScreenWithTopBar( @@ -124,10 +111,12 @@ private fun BodyMassScreen( ) { paddingValues -> Column( modifier = Modifier + .verticalScroll(rememberScrollState()) .padding(paddingValues) .padding(16.dp) .fillMaxSize(), - verticalArrangement = Arrangement.spacedBy(16.dp) + verticalArrangement = Arrangement.spacedBy(16.dp), + horizontalAlignment = Alignment.CenterHorizontally ) { SegmentedButtonsRow( modifier = Modifier.fillMaxWidth() @@ -193,7 +182,7 @@ private fun BodyMassScreen( modifier = Modifier.fillMaxWidth(), value = uiState.weight, onValueChange = updateWeight, - label = weightLabel, + label = "${stringResource(R.string.body_mass_weight)}, $weightShortLabel", expressionFormatter = expressionTransformer, imeAction = ImeAction.Done ) @@ -208,112 +197,44 @@ private fun BodyMassScreen( ) { targetState -> if (targetState.isEqualTo(BigDecimal.ZERO)) return@AnimatedContent - val value = remember(targetState) { - targetState - .format(3, OutputFormat.PLAIN) - .formatExpression(uiState.formatterSymbols) - } + BodyMassResult( + value = targetState, + range = uiState.normalWeightRange, + rangeSuffix = weightShortLabel, + formatterSymbols = uiState.formatterSymbols + ) + } - val classification = remember(targetState) { - getBodyMassData(targetState) - } - - Column( - modifier = Modifier - .clip(RoundedCornerShape(32.dp)) - .background(classification.color) - .padding(16.dp, 32.dp) - .fillMaxWidth(), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center - ) { - // TODO Link to web - - Text( - text = stringResource(classification.classification), - style = MaterialTheme.typography.displaySmall, - ) - Text( - text = value, - style = MaterialTheme.typography.bodyLarge - ) + ElevatedButton( + onClick = { + openLink(mContext, "https://sadellie.github.io/unitto/help#body-mass-index") } + ) { + Text(text = stringResource(R.string.time_zone_no_results_button)) // TODO Rename } } } } +@Preview @Composable -private fun BodyMassTextField( - modifier: Modifier, - value: TextFieldValue, - onValueChange: (TextFieldValue) -> Unit, - label: String, - expressionFormatter: VisualTransformation, - imeAction: ImeAction -) { - val focusManager = LocalFocusManager.current - OutlinedTextField( - modifier = modifier, - value = value, - onValueChange = { - val cleanText = it.text - .replace(",", ".") - .filter { char -> - Token.Digit.allWithDot.contains(char.toString()) - } - onValueChange(it.copy(cleanText)) - }, - label = { AnimatedContent(label) { Text(it) } }, - singleLine = true, - visualTransformation = expressionFormatter, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal, imeAction = imeAction), - keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }) +fun PreviewBodyMassScreen() { + BodyMassScreen( + uiState = UIState.Ready( + isMetric = false, + height1 = TextFieldValue(), + height2 = TextFieldValue(), + weight = TextFieldValue(), + normalWeightRange = BigDecimal(30) to BigDecimal(50), + result = BigDecimal(18.5), + allowVibration = false, + formatterSymbols = FormatterSymbols.Spaces + ), + updateHeight1 = {}, + updateHeight2 = {}, + updateWeight = {}, + updateIsMetric = {}, + openDrawer = {}, + navigateToSettings = {} ) } - -@Immutable -private data class BodyMassData( - val color: Color, - @StringRes val classification: Int -) - -@Stable -private fun getBodyMassData(value: BigDecimal): BodyMassData = when { - value.isLessThan(BigDecimal("18.5")) -> { - BodyMassData( - color = Color(0x800EACDD), - classification = R.string.body_mass_underweight - ) - } - value.isLessThan(BigDecimal("25")) -> { - BodyMassData( - color = Color(0x805BF724), - classification = R.string.body_mass_normal - ) - } - value.isLessThan(BigDecimal("30")) -> { - BodyMassData( - color = Color(0x80DBEC18), - classification = R.string.body_mass_overweight - ) - } - value.isLessThan(BigDecimal("35")) -> { - BodyMassData( - color = Color(0x80FF9634), - classification = R.string.body_mass_obese_1 - ) - } - value.isLessThan(BigDecimal("40")) -> { - BodyMassData( - color = Color(0x80F85F31), - classification = R.string.body_mass_obese_2 - ) - } - else -> { - BodyMassData( - color = Color(0x80FF2323), - classification = R.string.body_mass_obese_3 - ) - } -} diff --git a/feature/bodymass/src/main/java/com/sadellie/unitto/feature/bodymass/BodyMassUtils.kt b/feature/bodymass/src/main/java/com/sadellie/unitto/feature/bodymass/BodyMassUtils.kt new file mode 100644 index 00000000..91325b15 --- /dev/null +++ b/feature/bodymass/src/main/java/com/sadellie/unitto/feature/bodymass/BodyMassUtils.kt @@ -0,0 +1,112 @@ +/* + * Unitto is a unit converter 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 . + */ + +package com.sadellie.unitto.feature.bodymass + +import com.sadellie.unitto.core.base.MAX_PRECISION +import java.math.BigDecimal +import java.math.RoundingMode + +/** + * Calculates BMI value for metric system. + * + * @param heightCm Height in centimeters. + * @param weightKg Weight in kilograms. + * @return BMI Value with [MAX_PRECISION]. + */ +internal fun calculateMetric( + heightCm: BigDecimal, + weightKg: BigDecimal, +): BigDecimal { + val heightMeters = heightCm + .divide(cmToMFactor, MAX_PRECISION, RoundingMode.HALF_EVEN) + + return weightKg + .divide(heightMeters.pow(2), MAX_PRECISION, RoundingMode.HALF_EVEN) +} + +/** + * Calculates BMI value for imperial system. + * + * @param heightFt Height in feet. + * @param heightIn Height in inches. + * @param weightLbs Weight in pounds. + * @return BMI Value with [MAX_PRECISION]. + */ +internal fun calculateImperial( + heightFt: BigDecimal, + heightIn: BigDecimal, + weightLbs: BigDecimal +): BigDecimal { + val heightInches = heightFt + .multiply(footToInchFactor) + .plus(heightIn) + + return weightLbs + .divide(heightInches.pow(2), MAX_PRECISION, RoundingMode.HALF_EVEN) + .multiply(metricToImperialFactor) // Approximate lbs/inch^2 to kg/m^2 +} + +/** + * Calculates weight (kilograms) range for normal BMI values. + * + * @param heightCm Height in centimeters. + * @return [Pair] of [BigDecimal]. First value is lowest weight. Second value is highest value. + */ +internal fun calculateNormalWeightMetric( + heightCm: BigDecimal +): Pair { + val heightMetres2 = heightCm + .divide(cmToMFactor, MAX_PRECISION, RoundingMode.HALF_EVEN) + .pow(2) + + val lowest = lowestNormalIndex.multiply(heightMetres2) + val highest = highestNormalIndex.multiply(heightMetres2) + + return lowest to highest +} + +/** + * Calculates weight (pounds) range for normal BMI values. + * + * @param heightFt Height in feet. + * @param heightIn Height in inches. + * @return [Pair] of [BigDecimal]. First value is lowest weight. Second value is highest value. + */ +internal fun calculateNormalWeightImperial( + heightFt: BigDecimal, + heightIn: BigDecimal, +): Pair { + val heightInches2 = heightFt + .multiply(footToInchFactor) + .plus(heightIn) + .pow(2) + .divide(metricToImperialFactor, MAX_PRECISION, RoundingMode.HALF_EVEN) + + val lowest = lowestNormalIndex.multiply(heightInches2) + val highest = highestNormalIndex.multiply(heightInches2) + + return lowest to highest +} + +private val cmToMFactor = BigDecimal("100") +private val footToInchFactor = BigDecimal("12") +private val metricToImperialFactor = BigDecimal("703") + +private val lowestNormalIndex = BigDecimal("18.5") +private val highestNormalIndex = BigDecimal("25") diff --git a/feature/bodymass/src/main/java/com/sadellie/unitto/feature/bodymass/BodyMassViewModel.kt b/feature/bodymass/src/main/java/com/sadellie/unitto/feature/bodymass/BodyMassViewModel.kt index fa4d9066..3058a9dd 100644 --- a/feature/bodymass/src/main/java/com/sadellie/unitto/feature/bodymass/BodyMassViewModel.kt +++ b/feature/bodymass/src/main/java/com/sadellie/unitto/feature/bodymass/BodyMassViewModel.kt @@ -24,7 +24,6 @@ import android.os.Build import androidx.compose.ui.text.input.TextFieldValue import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.sadellie.unitto.core.base.MAX_PRECISION import com.sadellie.unitto.core.ui.common.textfield.AllFormatterSymbols import com.sadellie.unitto.data.common.combine import com.sadellie.unitto.data.common.stateIn @@ -36,7 +35,6 @@ import kotlinx.coroutines.flow.mapLatest import kotlinx.coroutines.flow.update import kotlinx.coroutines.withContext import java.math.BigDecimal -import java.math.RoundingMode import javax.inject.Inject @HiltViewModel @@ -48,9 +46,7 @@ internal class BodyMassViewModel @Inject constructor( private val _height2 = MutableStateFlow(TextFieldValue()) private val _weight = MutableStateFlow(TextFieldValue()) private val _result = MutableStateFlow(BigDecimal.ZERO) - private val cmToMFactor = BigDecimal("10000") - private val footToInchFactor = BigDecimal("12") - private val metricToImperialFactor = BigDecimal("703") + private val _normalWeightRange = MutableStateFlow>(BigDecimal.ZERO to BigDecimal.ZERO) val uiState = combine( userPreferencesRepository.bodyMassPrefs, @@ -58,35 +54,41 @@ internal class BodyMassViewModel @Inject constructor( _height1, _height2, _weight, - _result - ) { userPrefs, isMetric, height1, height2, weight, result -> + _result, + _normalWeightRange + ) { userPrefs, isMetric, height1, height2, weight, result, normalWeightRange -> UIState.Ready( isMetric = isMetric, height1 = height1, height2 = height2, weight = weight, result = result, + normalWeightRange = normalWeightRange, allowVibration = userPrefs.enableVibrations, formatterSymbols = AllFormatterSymbols.getById(userPrefs.separator) ) } .mapLatest { ui -> - val newResult: BigDecimal = try { - val height1 = BigDecimal(ui.height1.text.ifEmpty { "0" }) - val weight = BigDecimal(ui.weight.text.ifEmpty { "0" }) + withContext(Dispatchers.Default) { + try { + val height1 = BigDecimal(ui.height1.text.ifEmpty { "0" }) + val weight = BigDecimal(ui.weight.text.ifEmpty { "0" }) - if (ui.isMetric) { - calculateMetric(height1, weight) - } else { - val height2 = BigDecimal(ui.height2.text.ifEmpty { "0" }) - calculateImperial(height1, height2, weight) + if (ui.isMetric) { + _result.update { calculateMetric(height1, weight) } + _normalWeightRange.update { calculateNormalWeightMetric(height1) } + } else { + val height2 = BigDecimal(ui.height2.text.ifEmpty { "0" }) + + _result.update { calculateImperial(height1, height2, weight) } + _normalWeightRange.update { calculateNormalWeightImperial(height1, height2) } + } + } catch (e: Exception) { + _result.update { BigDecimal.ZERO } + _normalWeightRange.update { BigDecimal.ZERO to BigDecimal.ZERO } } - } catch (e: Exception) { - BigDecimal.ZERO } - _result.update { newResult } - ui } .stateIn(viewModelScope, UIState.Loading) @@ -96,29 +98,6 @@ internal class BodyMassViewModel @Inject constructor( fun updateWeight(textFieldValue: TextFieldValue) = _weight.update { textFieldValue } fun updateIsMetric(isMetric: Boolean) = _isMetric.update { isMetric } - private suspend fun calculateMetric( - height: BigDecimal, - weight: BigDecimal, - ) = withContext(Dispatchers.Default) { - return@withContext weight - .divide(height.pow(2), MAX_PRECISION, RoundingMode.HALF_EVEN) - .multiply(cmToMFactor) - } - - private suspend fun calculateImperial( - height1: BigDecimal, - height2: BigDecimal, - weight: BigDecimal - ) = withContext(Dispatchers.Default) { - val height = height1 - .multiply(footToInchFactor) - .plus(height2) - - return@withContext weight - .divide(height.pow(2), MAX_PRECISION, RoundingMode.HALF_EVEN) - .multiply(metricToImperialFactor) // Approximate lbs/inch^2 to kg/m^2 - } - private fun getInitialIsMetric(): Boolean { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P) return true return LocaleData.getMeasurementSystem(ULocale.getDefault()) != LocaleData.MeasurementSystem.US diff --git a/feature/bodymass/src/main/java/com/sadellie/unitto/feature/bodymass/UIState.kt b/feature/bodymass/src/main/java/com/sadellie/unitto/feature/bodymass/UIState.kt index b16c5a85..8d3153ea 100644 --- a/feature/bodymass/src/main/java/com/sadellie/unitto/feature/bodymass/UIState.kt +++ b/feature/bodymass/src/main/java/com/sadellie/unitto/feature/bodymass/UIState.kt @@ -30,6 +30,7 @@ internal sealed class UIState { val height1: TextFieldValue, val height2: TextFieldValue, val weight: TextFieldValue, + val normalWeightRange: Pair, val result: BigDecimal, val allowVibration: Boolean, val formatterSymbols: FormatterSymbols, diff --git a/feature/bodymass/src/main/java/com/sadellie/unitto/feature/bodymass/components/BodyMassResult.kt b/feature/bodymass/src/main/java/com/sadellie/unitto/feature/bodymass/components/BodyMassResult.kt new file mode 100644 index 00000000..cca83d06 --- /dev/null +++ b/feature/bodymass/src/main/java/com/sadellie/unitto/feature/bodymass/components/BodyMassResult.kt @@ -0,0 +1,176 @@ +/* + * Unitto is a unit converter 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 . + */ + +package com.sadellie.unitto.feature.bodymass.components + +import androidx.annotation.StringRes +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.Stable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.sadellie.unitto.core.base.OutputFormat +import com.sadellie.unitto.core.base.R +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 java.math.BigDecimal + +@Composable +internal fun BodyMassResult( + modifier: Modifier = Modifier, + value: BigDecimal, + range: Pair, + rangeSuffix: String, + formatterSymbols: FormatterSymbols, +) { + val formattedValue = remember(value, formatterSymbols) { + value + .format(3, OutputFormat.PLAIN) + .formatExpression(formatterSymbols) + } + + val formattedRange = remember(range, formatterSymbols) { + val low = range.first + .format(0, OutputFormat.PLAIN) + .formatExpression(formatterSymbols) + + val high = range.second + .format(0, OutputFormat.PLAIN) + .formatExpression(formatterSymbols) + + "$low – $high $rangeSuffix" + } + + val classification = remember(value) { + getBodyMassData(value) + } + + Column( + modifier = modifier, + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Column( + modifier = Modifier + .clip(RoundedCornerShape(32.dp)) + .background(classification.color) + .padding(16.dp, 32.dp) + .fillMaxWidth(), + ) { + Text( + text = stringResource(classification.classification), + style = MaterialTheme.typography.displaySmall, + color = MaterialTheme.colorScheme.onSecondaryContainer + ) + Text( + text = formattedValue, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSecondaryContainer + ) + } + + Column( + modifier = Modifier + .clip(RoundedCornerShape(32.dp)) + .background(MaterialTheme.colorScheme.secondaryContainer) + .padding(16.dp, 32.dp) + .fillMaxWidth(), + ) { + Text( + text = formattedRange, + style = MaterialTheme.typography.displaySmall, + color = MaterialTheme.colorScheme.onSecondaryContainer + ) + Text( + text = stringResource(R.string.body_mass_normal_weight), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSecondaryContainer + ) + } + } +} + +@Immutable +private data class BodyMassData( + val minValue: BigDecimal, + val color: Color, + @StringRes val classification: Int +) + +@Stable +private fun getBodyMassData(value: BigDecimal): BodyMassData = + indexes.first { value >= it.minValue } + +private val indexes by lazy { + listOf( + BodyMassData( + minValue = BigDecimal("40"), + color = Color(0x80FF2323), + classification = R.string.body_mass_obese_3 + ), + BodyMassData( + minValue = BigDecimal("35"), + color = Color(0x80F85F31), + classification = R.string.body_mass_obese_2 + ), + BodyMassData( + minValue = BigDecimal("30"), + color = Color(0x80FF9634), + classification = R.string.body_mass_obese_1 + ), + BodyMassData( + minValue = BigDecimal("25"), + color = Color(0x80DBEC18), + classification = R.string.body_mass_overweight + ), + BodyMassData( + minValue = BigDecimal("18.5"), + color = Color(0x805BF724), + classification = R.string.body_mass_normal + ), + BodyMassData( + minValue = BigDecimal("0"), + color = Color(0x800EACDD), + classification = R.string.body_mass_underweight + ), + ) +} + +@Preview +@Composable +fun PreviewBodyMassResult() { + BodyMassResult( + value = BigDecimal(18.5), + range = BigDecimal(50) to BigDecimal(80), + rangeSuffix = "kg", + formatterSymbols = FormatterSymbols.Spaces, + ) +} diff --git a/feature/bodymass/src/main/java/com/sadellie/unitto/feature/bodymass/components/BodyMassTextField.kt b/feature/bodymass/src/main/java/com/sadellie/unitto/feature/bodymass/components/BodyMassTextField.kt new file mode 100644 index 00000000..9fc447f1 --- /dev/null +++ b/feature/bodymass/src/main/java/com/sadellie/unitto/feature/bodymass/components/BodyMassTextField.kt @@ -0,0 +1,68 @@ +/* + * Unitto is a unit converter 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 . + */ + +package com.sadellie.unitto.feature.bodymass.components + +import androidx.compose.animation.AnimatedContent +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.LocalTextStyle +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.text.style.TextAlign +import com.sadellie.unitto.core.base.Token + +@Composable +internal fun BodyMassTextField( + modifier: Modifier, + value: TextFieldValue, + onValueChange: (TextFieldValue) -> Unit, + label: String, + expressionFormatter: VisualTransformation, + imeAction: ImeAction +) { + val focusManager = LocalFocusManager.current + OutlinedTextField( + modifier = modifier, + value = value, + onValueChange = { + val cleanText = it.text + .replace(",", ".") + .filter { char -> + Token.Digit.allWithDot.contains(char.toString()) + } + onValueChange(it.copy(cleanText)) + }, + label = { AnimatedContent(label) { Text(it) } }, + singleLine = true, + visualTransformation = expressionFormatter, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Decimal, + imeAction = imeAction + ), + textStyle = LocalTextStyle.current.copy(textAlign = TextAlign.End), + keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }) + ) +} diff --git a/feature/bodymass/src/test/java/com/sadellie/unitto/feature/bodymass/BodyMassTest.kt b/feature/bodymass/src/test/java/com/sadellie/unitto/feature/bodymass/BodyMassTest.kt new file mode 100644 index 00000000..c3933f0e --- /dev/null +++ b/feature/bodymass/src/test/java/com/sadellie/unitto/feature/bodymass/BodyMassTest.kt @@ -0,0 +1,72 @@ +/* + * Unitto is a unit converter 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 . + */ + +package com.sadellie.unitto.feature.bodymass + +import org.junit.Assert.assertEquals +import org.junit.Test +import java.math.BigDecimal +import java.math.RoundingMode + +class BodyMassTest { + @Test + fun testCalculateMetric() { + val height = BigDecimal("190") + val weight = BigDecimal("80") + val expected = BigDecimal("22.161").scaleDown() + val actual = calculateMetric(height, weight).scaleDown() + + assertEquals(expected, actual) + } + + @Test + fun testCalculateImperial() { + val height1 = BigDecimal("6") + val height2 = BigDecimal("5") + val weight = BigDecimal("150") + val expected = BigDecimal("17.785") + val actual = calculateImperial(height1, height2, weight).scaleDown() + + assertEquals(expected, actual) + } + + @Test + fun testCalculateNormalWeightMetric() { + val height = BigDecimal("190") + val expectedWeight1 = BigDecimal("66.785") + val expectedWeight2 = BigDecimal("90.250") + val actualWeight = calculateNormalWeightMetric(height) + + assertEquals(expectedWeight1, actualWeight.first.scaleDown()) + assertEquals(expectedWeight2, actualWeight.second.scaleDown()) + } + + @Test + fun testCalculateNormalWeightImperial() { + val heightFt = BigDecimal("6") + val heightIn = BigDecimal("5") + val expectedWeight1 = BigDecimal("156.026") + val expectedWeight2 = BigDecimal("210.846") + val actualWeight = calculateNormalWeightImperial(heightFt, heightIn) + + assertEquals(expectedWeight1, actualWeight.first.scaleDown()) + assertEquals(expectedWeight2, actualWeight.second.scaleDown()) + } + + private fun BigDecimal.scaleDown(): BigDecimal = setScale(3, RoundingMode.HALF_EVEN) +}