BMI calculator

closes #134

squashed commit
This commit is contained in:
Sad Ellie 2024-01-06 18:42:40 +03:00
parent 37f25ed3ba
commit 02e9bfaf90
21 changed files with 791 additions and 26 deletions

View File

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

View File

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

View File

@ -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(
var all = listOf(
TopLevelDestinations.Calculator,
TopLevelDestinations.Converter,
TopLevelDestinations.DateCalculator,
TopLevelDestinations.TimeZone,
TopLevelDestinations.BodyMass,
)
} else {
listOf(
TopLevelDestinations.Calculator,
TopLevelDestinations.Converter,
TopLevelDestinations.DateCalculator,
)
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) {
all = all - TopLevelDestinations.TimeZone
}
all
}
// Only routes, not graphs!

View File

@ -0,0 +1,25 @@
<?xml version="1.0" encoding="utf-8"?><!--
~ 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 <https://www.gnu.org/licenses/>.
-->
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_shortcut_background" />
<foreground>
<inset
android:drawable="@drawable/ic_body_mass_foreground"
android:inset="29.999996%" />
</foreground>
</adaptive-icon>

View File

@ -0,0 +1,31 @@
<!--
~ 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 <https://www.gnu.org/licenses/>.
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<group>
<clip-path
android:pathData="M0,0h24v24h-24z"/>
<path
android:pathData="M6,19H18L16.575,9H7.425L6,19ZM12,7C12.283,7 12.521,6.904 12.713,6.713C12.904,6.521 13,6.283 13,6C13,5.717 12.904,5.479 12.713,5.287C12.521,5.096 12.283,5 12,5C11.717,5 11.479,5.096 11.288,5.287C11.096,5.479 11,5.717 11,6C11,6.283 11.096,6.521 11.288,6.713C11.479,6.904 11.717,7 12,7ZM14.825,7H16.575C17.075,7 17.508,7.167 17.875,7.5C18.242,7.833 18.467,8.242 18.55,8.725L19.975,18.725C20.058,19.325 19.904,19.854 19.513,20.313C19.121,20.771 18.617,21 18,21H6C5.383,21 4.879,20.771 4.488,20.313C4.096,19.854 3.942,19.325 4.025,18.725L5.45,8.725C5.533,8.242 5.758,7.833 6.125,7.5C6.492,7.167 6.925,7 7.425,7H9.175C9.125,6.833 9.083,6.671 9.05,6.512C9.017,6.354 9,6.183 9,6C9,5.167 9.292,4.458 9.875,3.875C10.458,3.292 11.167,3 12,3C12.833,3 13.542,3.292 14.125,3.875C14.708,4.458 15,5.167 15,6C15,6.183 14.983,6.354 14.95,6.512C14.917,6.671 14.875,6.833 14.825,7Z"
android:fillColor="@color/unitto_primary"/>
</group>
</vector>

View File

@ -0,0 +1,37 @@
<?xml version="1.0" encoding="utf-8"?><!--
~ 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 <https://www.gnu.org/licenses/>.
-->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item
android:bottom="2.0dip"
android:left="2.0dip"
android:right="2.0dip"
android:top="2.0dip">
<shape android:shape="oval">
<size
android:width="44.0dip"
android:height="44.0dip" />
<solid android:color="@color/ic_shortcut_background" />
</shape>
</item>
<item
android:bottom="12.0dip"
android:drawable="@drawable/ic_body_mass_foreground"
android:left="12.0dip"
android:right="12.0dip"
android:top="12.0dip" />
</layer-list>

View File

@ -4,6 +4,23 @@
<!-- https://s3.eu-west-1.amazonaws.com/po-pub/i/QNmUuI88wb1Fm5P8wRIZajZF.png -->
<string name="add_label">Add</string>
<string name="app_name" translatable="false">Unitto</string>
<!-- Person's height -->
<string name="body_mass_height">Height</string>
<string name="body_mass_imperial">Imperial</string>
<string name="body_mass_metric">Metric</string>
<string name="body_mass_normal">Normal weight</string>
<string name="body_mass_obese_1">Obese (Class 1)</string>
<string name="body_mass_obese_2">Obese (Class 2)</string>
<string name="body_mass_obese_3">Obese (Class 3)</string>
<string name="body_mass_overweight">Overweight</string>
<!-- Also known as Body mass index (BMI) -->
<string name="body_mass_title">Body mass</string>
<string name="body_mass_underweight">Underweight</string>
<!-- Person's weight -->
<string name="body_mass_weight">Weight</string>
<string name="calculator_clear_history_support">All expressions from history will be deleted forever. This action can\'t be undone!</string>
<string name="calculator_divide_by_zero_error">Can\'t divide by 0</string>
<string name="calculator_no_history">No history</string>

View File

@ -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(
var all = listOf(
Calculator,
Converter,
DateDifference,
TimeZones,
BodyMass
)
} else {
listOf(
Calculator,
Converter,
DateDifference,
)
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) {
all = all - TimeZones
}
all
}
}
}

View File

@ -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<FormattingPreferences>
val unitGroupsPrefs: Flow<UnitGroupsPreferences>
val addSubtractPrefs: Flow<AddSubtractPreferences>
val bodyMassPrefs: Flow<BodyMassPreferences>
val aboutPrefs: Flow<AboutPreferences>
val startingScreenPrefs: Flow<StartingScreenPreferences>

View File

@ -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 <https://www.gnu.org/licenses/>.
*/
package com.sadellie.unitto.data.model.userprefs
interface BodyMassPreferences{
val separator: Int
val enableVibrations: Boolean
}

View File

@ -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

View File

@ -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<BodyMassPreferences> = data
.map { preferences ->
BodyMassPreferencesImpl(
separator = preferences.getSeparator(),
enableVibrations = preferences.getEnableVibrations(),
)
}
override val aboutPrefs: Flow<AboutPreferences> = data
.map { preferences ->
AboutPreferencesImpl(

1
feature/bodymass/.gitignore vendored Normal file
View File

@ -0,0 +1 @@
/build

View File

@ -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 <https://www.gnu.org/licenses/>.
*/
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"))
}

View File

View File

@ -0,0 +1,21 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
~ 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 <https://www.gnu.org/licenses/>.
-->
<manifest>
</manifest>

View File

@ -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 <https://www.gnu.org/licenses/>.
*/
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
)
}
}

View File

@ -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 <https://www.gnu.org/licenses/>.
*/
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>(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
}
}

View File

@ -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 <https://www.gnu.org/licenses/>.
*/
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()
}

View File

@ -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 <https://www.gnu.org/licenses/>.
*/
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
)
}
}
}

View File

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