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)
+}