From 02e9bfaf90bbd2d3127feafed02600277e964dfc Mon Sep 17 00:00:00 2001 From: Sad Ellie Date: Sat, 6 Jan 2024 18:42:40 +0300 Subject: [PATCH] BMI calculator closes #134 squashed commit --- app/build.gradle.kts | 1 + .../com/sadellie/unitto/UnittoNavigation.kt | 6 + .../unitto/core/base/TopLevelDestinations.kt | 39 ++- .../ic_shortcut_body_mass.xml | 25 ++ .../res/drawable/ic_body_mass_foreground.xml | 31 ++ .../res/drawable/ic_shortcut_body_mass.xml | 37 ++ core/base/src/main/res/values/strings.xml | 17 + .../unitto/core/ui/model/DrawerItems.kt | 33 +- .../repository/UserPreferencesRepository.kt | 2 + .../model/userprefs/BodyMassPreferences.kt | 24 ++ .../unitto/data/userprefs/PreferenceModels.kt | 6 + .../unitto/data/userprefs/UserPreferences.kt | 9 + feature/bodymass/.gitignore | 1 + feature/bodymass/build.gradle.kts | 33 ++ feature/bodymass/consumer-rules.pro | 0 feature/bodymass/src/main/AndroidManifest.xml | 21 ++ .../unitto/feature/bodymass/BodyMassScreen.kt | 319 ++++++++++++++++++ .../feature/bodymass/BodyMassViewModel.kt | 126 +++++++ .../unitto/feature/bodymass/UIState.kt | 37 ++ .../feature/bodymass/navigation/Navigation.kt | 49 +++ settings.gradle.kts | 1 + 21 files changed, 791 insertions(+), 26 deletions(-) create mode 100644 core/base/src/main/res/drawable-anydpi-v26/ic_shortcut_body_mass.xml create mode 100644 core/base/src/main/res/drawable/ic_body_mass_foreground.xml create mode 100644 core/base/src/main/res/drawable/ic_shortcut_body_mass.xml create mode 100644 data/model/src/main/java/com/sadellie/unitto/data/model/userprefs/BodyMassPreferences.kt create mode 100644 feature/bodymass/.gitignore create mode 100644 feature/bodymass/build.gradle.kts create mode 100644 feature/bodymass/consumer-rules.pro create mode 100644 feature/bodymass/src/main/AndroidManifest.xml create mode 100644 feature/bodymass/src/main/java/com/sadellie/unitto/feature/bodymass/BodyMassScreen.kt create mode 100644 feature/bodymass/src/main/java/com/sadellie/unitto/feature/bodymass/BodyMassViewModel.kt create mode 100644 feature/bodymass/src/main/java/com/sadellie/unitto/feature/bodymass/UIState.kt create mode 100644 feature/bodymass/src/main/java/com/sadellie/unitto/feature/bodymass/navigation/Navigation.kt 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")