diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index e5df3f3b..430b5cda 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -138,6 +138,7 @@ dependencies {
implementation(project(":feature:settings"))
implementation(project(":feature:datecalculator"))
implementation(project(":feature:timezone"))
+ implementation(project(":feature:bodymass"))
implementation(project(":feature:glance"))
implementation(project(":data:model"))
implementation(project(":data:userprefs"))
diff --git a/app/src/main/java/com/sadellie/unitto/UnittoNavigation.kt b/app/src/main/java/com/sadellie/unitto/UnittoNavigation.kt
index df1e9dd4..6aac3ba8 100644
--- a/app/src/main/java/com/sadellie/unitto/UnittoNavigation.kt
+++ b/app/src/main/java/com/sadellie/unitto/UnittoNavigation.kt
@@ -26,6 +26,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.navigation.NavHostController
import androidx.navigation.compose.NavHost
+import com.sadellie.unitto.feature.bodymass.navigation.bodyMassGraph
import com.sadellie.unitto.feature.calculator.navigation.calculatorGraph
import com.sadellie.unitto.feature.converter.navigation.converterGraph
import com.sadellie.unitto.feature.datecalculator.navigation.dateCalculatorGraph
@@ -78,5 +79,10 @@ internal fun UnittoNavigation(
navigateToSettings = navController::navigateToSettings,
navController = navController,
)
+
+ bodyMassGraph(
+ openDrawer = openDrawer,
+ navigateToSettings = navController::navigateToSettings,
+ )
}
}
diff --git a/core/base/src/main/java/com/sadellie/unitto/core/base/TopLevelDestinations.kt b/core/base/src/main/java/com/sadellie/unitto/core/base/TopLevelDestinations.kt
index 47b02ce6..5a12da5a 100644
--- a/core/base/src/main/java/com/sadellie/unitto/core/base/TopLevelDestinations.kt
+++ b/core/base/src/main/java/com/sadellie/unitto/core/base/TopLevelDestinations.kt
@@ -35,6 +35,9 @@ private const val DATE_CALCULATOR_START = "date_calculator_start"
private const val TIME_ZONE_GRAPH = "time_zone_route"
private const val TIME_ZONE_START = "time_zone_start"
+private const val BODY_MASS_GRAPH = "body_mass_route"
+private const val BODY_MASS_START = "body_mass_start"
+
private const val SETTINGS_GRAPH = "settings_route"
private const val SETTINGS_START = "settings_start"
@@ -94,6 +97,17 @@ sealed class TopLevelDestinations(
)
)
+ data object BodyMass : TopLevelDestinations(
+ graph = BODY_MASS_GRAPH,
+ start = BODY_MASS_START,
+ name = R.string.body_mass_title,
+ shortcut = Shortcut(
+ R.string.body_mass_title,
+ R.string.body_mass_title,
+ R.drawable.ic_shortcut_body_mass
+ )
+ )
+
data object Settings : TopLevelDestinations(
graph = SETTINGS_GRAPH,
start = SETTINGS_START,
@@ -103,20 +117,19 @@ sealed class TopLevelDestinations(
// Shown in settings
val TOP_LEVEL_DESTINATIONS by lazy {
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
- listOf(
- TopLevelDestinations.Calculator,
- TopLevelDestinations.Converter,
- TopLevelDestinations.DateCalculator,
- TopLevelDestinations.TimeZone,
- )
- } else {
- listOf(
- TopLevelDestinations.Calculator,
- TopLevelDestinations.Converter,
- TopLevelDestinations.DateCalculator,
- )
+ var all = listOf(
+ TopLevelDestinations.Calculator,
+ TopLevelDestinations.Converter,
+ TopLevelDestinations.DateCalculator,
+ TopLevelDestinations.TimeZone,
+ TopLevelDestinations.BodyMass,
+ )
+
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) {
+ all = all - TopLevelDestinations.TimeZone
}
+
+ all
}
// Only routes, not graphs!
diff --git a/core/base/src/main/res/drawable-anydpi-v26/ic_shortcut_body_mass.xml b/core/base/src/main/res/drawable-anydpi-v26/ic_shortcut_body_mass.xml
new file mode 100644
index 00000000..a310ef25
--- /dev/null
+++ b/core/base/src/main/res/drawable-anydpi-v26/ic_shortcut_body_mass.xml
@@ -0,0 +1,25 @@
+
+
+
+
+
+
+
diff --git a/core/base/src/main/res/drawable/ic_body_mass_foreground.xml b/core/base/src/main/res/drawable/ic_body_mass_foreground.xml
new file mode 100644
index 00000000..4ca2169c
--- /dev/null
+++ b/core/base/src/main/res/drawable/ic_body_mass_foreground.xml
@@ -0,0 +1,31 @@
+
+
+
+
+
+
+
+
diff --git a/core/base/src/main/res/drawable/ic_shortcut_body_mass.xml b/core/base/src/main/res/drawable/ic_shortcut_body_mass.xml
new file mode 100644
index 00000000..94d9607a
--- /dev/null
+++ b/core/base/src/main/res/drawable/ic_shortcut_body_mass.xml
@@ -0,0 +1,37 @@
+
+
+ -
+
+
+
+
+
+
+
diff --git a/core/base/src/main/res/values/strings.xml b/core/base/src/main/res/values/strings.xml
index e10b7ee5..8b4f3078 100644
--- a/core/base/src/main/res/values/strings.xml
+++ b/core/base/src/main/res/values/strings.xml
@@ -4,6 +4,23 @@
Add
Unitto
+
+
+ Height
+ Imperial
+ Metric
+ Normal weight
+ Obese (Class 1)
+ Obese (Class 2)
+ Obese (Class 3)
+ Overweight
+
+
+ Body mass
+ Underweight
+
+
+ Weight
All expressions from history will be deleted forever. This action can\'t be undone!
Can\'t divide by 0
No history
diff --git a/core/ui/src/main/java/com/sadellie/unitto/core/ui/model/DrawerItems.kt b/core/ui/src/main/java/com/sadellie/unitto/core/ui/model/DrawerItems.kt
index 96171d4b..89d75fe9 100644
--- a/core/ui/src/main/java/com/sadellie/unitto/core/ui/model/DrawerItems.kt
+++ b/core/ui/src/main/java/com/sadellie/unitto/core/ui/model/DrawerItems.kt
@@ -20,11 +20,13 @@ package com.sadellie.unitto.core.ui.model
import android.os.Build
import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Accessibility
import androidx.compose.material.icons.filled.Calculate
import androidx.compose.material.icons.filled.Event
import androidx.compose.material.icons.filled.Schedule
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.SwapHoriz
+import androidx.compose.material.icons.outlined.Accessibility
import androidx.compose.material.icons.outlined.Calculate
import androidx.compose.material.icons.outlined.Event
import androidx.compose.material.icons.outlined.Schedule
@@ -62,6 +64,12 @@ sealed class DrawerItems(
defaultIcon = Icons.Outlined.Schedule
)
+ data object BodyMass : DrawerItems(
+ destination = TopLevelDestinations.BodyMass,
+ selectedIcon = Icons.Filled.Accessibility, // temporary
+ defaultIcon = Icons.Outlined.Accessibility // temporary
+ )
+
data object Settings : DrawerItems(
destination = TopLevelDestinations.Settings,
selectedIcon = Icons.Filled.Settings,
@@ -73,20 +81,19 @@ sealed class DrawerItems(
* Excluding Settings tab since it appears only for expanded layout
*/
val MAIN by lazy {
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
- listOf(
- Calculator,
- Converter,
- DateDifference,
- TimeZones,
- )
- } else {
- listOf(
- Calculator,
- Converter,
- DateDifference,
- )
+ var all = listOf(
+ Calculator,
+ Converter,
+ DateDifference,
+ TimeZones,
+ BodyMass
+ )
+
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) {
+ all = all - TimeZones
}
+
+ all
}
}
}
diff --git a/data/model/src/main/java/com/sadellie/unitto/data/model/repository/UserPreferencesRepository.kt b/data/model/src/main/java/com/sadellie/unitto/data/model/repository/UserPreferencesRepository.kt
index 98c4156a..9a7a9115 100644
--- a/data/model/src/main/java/com/sadellie/unitto/data/model/repository/UserPreferencesRepository.kt
+++ b/data/model/src/main/java/com/sadellie/unitto/data/model/repository/UserPreferencesRepository.kt
@@ -24,6 +24,7 @@ import com.sadellie.unitto.data.model.unit.AbstractUnit
import com.sadellie.unitto.data.model.userprefs.AboutPreferences
import com.sadellie.unitto.data.model.userprefs.AddSubtractPreferences
import com.sadellie.unitto.data.model.userprefs.AppPreferences
+import com.sadellie.unitto.data.model.userprefs.BodyMassPreferences
import com.sadellie.unitto.data.model.userprefs.CalculatorPreferences
import com.sadellie.unitto.data.model.userprefs.ConverterPreferences
import com.sadellie.unitto.data.model.userprefs.DisplayPreferences
@@ -42,6 +43,7 @@ interface UserPreferencesRepository {
val formattingPrefs: Flow
val unitGroupsPrefs: Flow
val addSubtractPrefs: Flow
+ val bodyMassPrefs: Flow
val aboutPrefs: Flow
val startingScreenPrefs: Flow
diff --git a/data/model/src/main/java/com/sadellie/unitto/data/model/userprefs/BodyMassPreferences.kt b/data/model/src/main/java/com/sadellie/unitto/data/model/userprefs/BodyMassPreferences.kt
new file mode 100644
index 00000000..54a07dd6
--- /dev/null
+++ b/data/model/src/main/java/com/sadellie/unitto/data/model/userprefs/BodyMassPreferences.kt
@@ -0,0 +1,24 @@
+/*
+ * Unitto is a unit converter for Android
+ * Copyright (c) 2023 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.data.model.userprefs
+
+interface BodyMassPreferences{
+ val separator: Int
+ val enableVibrations: Boolean
+}
diff --git a/data/userprefs/src/main/java/com/sadellie/unitto/data/userprefs/PreferenceModels.kt b/data/userprefs/src/main/java/com/sadellie/unitto/data/userprefs/PreferenceModels.kt
index 1e00d073..b0245ecb 100644
--- a/data/userprefs/src/main/java/com/sadellie/unitto/data/userprefs/PreferenceModels.kt
+++ b/data/userprefs/src/main/java/com/sadellie/unitto/data/userprefs/PreferenceModels.kt
@@ -24,6 +24,7 @@ import com.sadellie.unitto.data.model.UnitsListSorting
import com.sadellie.unitto.data.model.userprefs.AboutPreferences
import com.sadellie.unitto.data.model.userprefs.AddSubtractPreferences
import com.sadellie.unitto.data.model.userprefs.AppPreferences
+import com.sadellie.unitto.data.model.userprefs.BodyMassPreferences
import com.sadellie.unitto.data.model.userprefs.CalculatorPreferences
import com.sadellie.unitto.data.model.userprefs.ConverterPreferences
import com.sadellie.unitto.data.model.userprefs.DisplayPreferences
@@ -96,6 +97,11 @@ data class AddSubtractPreferencesImpl(
override val enableVibrations: Boolean,
) : AddSubtractPreferences
+data class BodyMassPreferencesImpl(
+ override val separator: Int,
+ override val enableVibrations: Boolean,
+) : BodyMassPreferences
+
data class AboutPreferencesImpl(
override val enableToolsExperiment: Boolean,
) : AboutPreferences
diff --git a/data/userprefs/src/main/java/com/sadellie/unitto/data/userprefs/UserPreferences.kt b/data/userprefs/src/main/java/com/sadellie/unitto/data/userprefs/UserPreferences.kt
index 515e2c19..3be7cd6d 100644
--- a/data/userprefs/src/main/java/com/sadellie/unitto/data/userprefs/UserPreferences.kt
+++ b/data/userprefs/src/main/java/com/sadellie/unitto/data/userprefs/UserPreferences.kt
@@ -29,6 +29,7 @@ import com.sadellie.unitto.data.model.unit.AbstractUnit
import com.sadellie.unitto.data.model.userprefs.AboutPreferences
import com.sadellie.unitto.data.model.userprefs.AddSubtractPreferences
import com.sadellie.unitto.data.model.userprefs.AppPreferences
+import com.sadellie.unitto.data.model.userprefs.BodyMassPreferences
import com.sadellie.unitto.data.model.userprefs.CalculatorPreferences
import com.sadellie.unitto.data.model.userprefs.ConverterPreferences
import com.sadellie.unitto.data.model.userprefs.DisplayPreferences
@@ -136,6 +137,14 @@ class UserPreferencesRepositoryImpl @Inject constructor(
)
}
+ override val bodyMassPrefs: Flow = data
+ .map { preferences ->
+ BodyMassPreferencesImpl(
+ separator = preferences.getSeparator(),
+ enableVibrations = preferences.getEnableVibrations(),
+ )
+ }
+
override val aboutPrefs: Flow = data
.map { preferences ->
AboutPreferencesImpl(
diff --git a/feature/bodymass/.gitignore b/feature/bodymass/.gitignore
new file mode 100644
index 00000000..42afabfd
--- /dev/null
+++ b/feature/bodymass/.gitignore
@@ -0,0 +1 @@
+/build
\ No newline at end of file
diff --git a/feature/bodymass/build.gradle.kts b/feature/bodymass/build.gradle.kts
new file mode 100644
index 00000000..3d2ed2ea
--- /dev/null
+++ b/feature/bodymass/build.gradle.kts
@@ -0,0 +1,33 @@
+/*
+ * 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 .
+ */
+
+plugins {
+ id("unitto.library")
+ id("unitto.library.compose")
+ id("unitto.library.feature")
+ id("unitto.android.hilt")
+ id("unitto.android.library.jacoco")
+}
+
+android.namespace = "com.sadellie.unitto.feature.bodymass"
+
+dependencies {
+ implementation(project(":data:common"))
+ implementation(project(":data:model"))
+ implementation(project(":data:userprefs"))
+}
diff --git a/feature/bodymass/consumer-rules.pro b/feature/bodymass/consumer-rules.pro
new file mode 100644
index 00000000..e69de29b
diff --git a/feature/bodymass/src/main/AndroidManifest.xml b/feature/bodymass/src/main/AndroidManifest.xml
new file mode 100644
index 00000000..3c1e98a9
--- /dev/null
+++ b/feature/bodymass/src/main/AndroidManifest.xml
@@ -0,0 +1,21 @@
+
+
+
+
+
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
new file mode 100644
index 00000000..c50c79d6
--- /dev/null
+++ b/feature/bodymass/src/main/java/com/sadellie/unitto/feature/bodymass/BodyMassScreen.kt
@@ -0,0 +1,319 @@
+/*
+ * 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 androidx.annotation.StringRes
+import androidx.compose.animation.AnimatedContent
+import androidx.compose.animation.Crossfade
+import androidx.compose.animation.SizeTransform
+import androidx.compose.animation.fadeIn
+import androidx.compose.animation.fadeOut
+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.Row
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.text.KeyboardActions
+import androidx.compose.foundation.text.KeyboardOptions
+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.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
+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.data.common.isEqualTo
+import com.sadellie.unitto.data.common.isLessThan
+import java.math.BigDecimal
+
+@Composable
+internal fun BodyMassRoute(
+ openDrawer: () -> Unit,
+ navigateToSettings: () -> Unit,
+ viewModel: BodyMassViewModel = hiltViewModel()
+) {
+ when (val uiState = viewModel.uiState.collectAsStateWithLifecycle().value) {
+ UIState.Loading -> UnittoEmptyScreen()
+ is UIState.Ready -> BodyMassScreen(
+ uiState = uiState,
+ updateHeight1 = viewModel::updateHeight1,
+ updateHeight2 = viewModel::updateHeight2,
+ updateWeight = viewModel::updateWeight,
+ updateIsMetric = viewModel::updateIsMetric,
+ openDrawer = openDrawer,
+ navigateToSettings = navigateToSettings
+ )
+ }
+}
+
+@Composable
+private fun BodyMassScreen(
+ uiState: UIState.Ready,
+ updateHeight1: (TextFieldValue) -> Unit,
+ updateHeight2: (TextFieldValue) -> Unit,
+ updateWeight: (TextFieldValue) -> Unit,
+ updateIsMetric: (Boolean) -> Unit,
+ openDrawer: () -> Unit,
+ navigateToSettings: () -> Unit,
+) {
+ val mContext = LocalContext.current
+ 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"
+ }
+
+ UnittoScreenWithTopBar(
+ title = { Text(stringResource(R.string.body_mass_title)) },
+ navigationIcon = { MenuButton(openDrawer) },
+ actions = { SettingsButton(navigateToSettings) }
+ ) { paddingValues ->
+ Column(
+ modifier = Modifier
+ .padding(paddingValues)
+ .padding(16.dp)
+ .fillMaxSize(),
+ verticalArrangement = Arrangement.spacedBy(16.dp)
+ ) {
+ SegmentedButtonsRow(
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ SegmentedButton(
+ label = stringResource(R.string.body_mass_metric),
+ onClick = { updateIsMetric(true) },
+ selected = uiState.isMetric,
+ modifier = Modifier.weight(1f)
+ )
+ SegmentedButton(
+ label = stringResource(R.string.body_mass_imperial),
+ onClick = { updateIsMetric(false) },
+ selected = !uiState.isMetric,
+ modifier = Modifier.weight(1f)
+ )
+ }
+
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .background(
+ MaterialTheme.colorScheme.secondaryContainer,
+ RoundedCornerShape(32.dp)
+ )
+ .padding(16.dp, 24.dp),
+ verticalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ Crossfade(targetState = uiState.isMetric) { isMetric ->
+ if (isMetric) {
+ BodyMassTextField(
+ modifier = Modifier.fillMaxWidth(),
+ value = uiState.height1,
+ onValueChange = updateHeight1,
+ label = "${stringResource(R.string.body_mass_height)}, ${stringResource(R.string.unit_centimeter_short)}",
+ expressionFormatter = expressionTransformer,
+ imeAction = ImeAction.Next
+ )
+ } else {
+ Row(
+ horizontalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ BodyMassTextField(
+ modifier = Modifier.weight(1f),
+ value = uiState.height1,
+ onValueChange = updateHeight1,
+ label = "${stringResource(R.string.body_mass_height)}, ${stringResource(R.string.unit_foot_short)}",
+ expressionFormatter = expressionTransformer,
+ imeAction = ImeAction.Next
+ )
+ BodyMassTextField(
+ modifier = Modifier.weight(1f),
+ value = uiState.height2,
+ onValueChange = updateHeight2,
+ label = "${stringResource(R.string.body_mass_height)}, ${stringResource(R.string.unit_inch_short)}",
+ expressionFormatter = expressionTransformer,
+ imeAction = ImeAction.Next
+ )
+ }
+ }
+ }
+ BodyMassTextField(
+ modifier = Modifier.fillMaxWidth(),
+ value = uiState.weight,
+ onValueChange = updateWeight,
+ label = weightLabel,
+ expressionFormatter = expressionTransformer,
+ imeAction = ImeAction.Done
+ )
+ }
+
+ AnimatedContent(
+ modifier = Modifier.fillMaxWidth(),
+ targetState = uiState.result,
+ transitionSpec = {
+ (fadeIn() togetherWith fadeOut()) using SizeTransform(false)
+ }
+ ) { targetState ->
+ if (targetState.isEqualTo(BigDecimal.ZERO)) return@AnimatedContent
+
+ val value = remember(targetState) {
+ targetState
+ .format(3, OutputFormat.PLAIN)
+ .formatExpression(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
+ )
+ }
+ }
+ }
+ }
+}
+
+@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() })
+ )
+}
+
+@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/BodyMassViewModel.kt b/feature/bodymass/src/main/java/com/sadellie/unitto/feature/bodymass/BodyMassViewModel.kt
new file mode 100644
index 00000000..fa4d9066
--- /dev/null
+++ b/feature/bodymass/src/main/java/com/sadellie/unitto/feature/bodymass/BodyMassViewModel.kt
@@ -0,0 +1,126 @@
+/*
+ * 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 android.icu.util.LocaleData
+import android.icu.util.ULocale
+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
+import com.sadellie.unitto.data.model.repository.UserPreferencesRepository
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.MutableStateFlow
+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
+internal class BodyMassViewModel @Inject constructor(
+ userPreferencesRepository: UserPreferencesRepository
+): ViewModel() {
+ private val _isMetric = MutableStateFlow(getInitialIsMetric())
+ private val _height1 = MutableStateFlow(TextFieldValue())
+ 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")
+
+ val uiState = combine(
+ userPreferencesRepository.bodyMassPrefs,
+ _isMetric,
+ _height1,
+ _height2,
+ _weight,
+ _result
+ ) { userPrefs, isMetric, height1, height2, weight, result ->
+ UIState.Ready(
+ isMetric = isMetric,
+ height1 = height1,
+ height2 = height2,
+ weight = weight,
+ result = result,
+ 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" })
+
+ if (ui.isMetric) {
+ calculateMetric(height1, weight)
+ } else {
+ val height2 = BigDecimal(ui.height2.text.ifEmpty { "0" })
+ calculateImperial(height1, height2, weight)
+ }
+ } catch (e: Exception) {
+ BigDecimal.ZERO
+ }
+
+ _result.update { newResult }
+
+ ui
+ }
+ .stateIn(viewModelScope, UIState.Loading)
+
+ fun updateHeight1(textFieldValue: TextFieldValue) = _height1.update { textFieldValue }
+ fun updateHeight2(textFieldValue: TextFieldValue) = _height2.update { textFieldValue }
+ 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
new file mode 100644
index 00000000..b16c5a85
--- /dev/null
+++ b/feature/bodymass/src/main/java/com/sadellie/unitto/feature/bodymass/UIState.kt
@@ -0,0 +1,37 @@
+/*
+ * 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 androidx.compose.ui.text.input.TextFieldValue
+import com.sadellie.unitto.core.ui.common.textfield.FormatterSymbols
+import java.math.BigDecimal
+
+internal sealed class UIState {
+ data object Loading : UIState()
+
+ data class Ready(
+ val isMetric: Boolean,
+ val height1: TextFieldValue,
+ val height2: TextFieldValue,
+ val weight: TextFieldValue,
+ val result: BigDecimal,
+ val allowVibration: Boolean,
+ val formatterSymbols: FormatterSymbols,
+ ) : UIState()
+}
diff --git a/feature/bodymass/src/main/java/com/sadellie/unitto/feature/bodymass/navigation/Navigation.kt b/feature/bodymass/src/main/java/com/sadellie/unitto/feature/bodymass/navigation/Navigation.kt
new file mode 100644
index 00000000..660e6064
--- /dev/null
+++ b/feature/bodymass/src/main/java/com/sadellie/unitto/feature/bodymass/navigation/Navigation.kt
@@ -0,0 +1,49 @@
+/*
+ * 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.navigation
+
+import androidx.navigation.NavGraphBuilder
+import androidx.navigation.navDeepLink
+import com.sadellie.unitto.core.base.TopLevelDestinations
+import com.sadellie.unitto.core.ui.unittoComposable
+import com.sadellie.unitto.core.ui.unittoNavigation
+import com.sadellie.unitto.feature.bodymass.BodyMassRoute
+
+private val graph = TopLevelDestinations.BodyMass.graph
+private val start = TopLevelDestinations.BodyMass.start
+
+fun NavGraphBuilder.bodyMassGraph(
+ openDrawer: () -> Unit,
+ navigateToSettings: () -> Unit
+) {
+ unittoNavigation(
+ startDestination = start,
+ route = graph,
+ deepLinks = listOf(
+ navDeepLink { uriPattern = "app://com.sadellie.unitto/$graph" }
+ )
+ ) {
+ unittoComposable(start) {
+ BodyMassRoute(
+ openDrawer = openDrawer,
+ navigateToSettings = navigateToSettings
+ )
+ }
+ }
+}
diff --git a/settings.gradle.kts b/settings.gradle.kts
index 5d6d7b6a..4165c997 100644
--- a/settings.gradle.kts
+++ b/settings.gradle.kts
@@ -25,6 +25,7 @@ include(":feature:converter")
include(":feature:calculator")
include(":feature:datecalculator")
include(":feature:timezone")
+include(":feature:bodymass")
include(":feature:settings")
include(":feature:glance")
include(":data:userprefs")