From 7be6b9575cd3fd4d52465e05d50df75fc380c98e Mon Sep 17 00:00:00 2001 From: oliexdev Date: Fri, 29 Aug 2025 20:17:15 +0200 Subject: [PATCH] This commit introduces the ability to select and apply body composition formulas (Body Fat, Body Water, LBM) within the measurement type detail screen. --- .../com/health/openscale/core/data/Enums.kt | 95 ++- .../openscale/core/facade/SettingsFacade.kt | 64 ++ .../core/usecase/BodyCompositionUseCases.kt | 215 +++++++ .../core/usecase/MeasurementCrudUseCases.kt | 27 +- .../settings/MeasurementTypeDetailScreen.kt | 602 ++++++++++++------ .../app/src/main/res/values-de/strings.xml | 46 ++ .../app/src/main/res/values/strings.xml | 47 ++ 7 files changed, 878 insertions(+), 218 deletions(-) create mode 100644 android_app/app/src/main/java/com/health/openscale/core/usecase/BodyCompositionUseCases.kt diff --git a/android_app/app/src/main/java/com/health/openscale/core/data/Enums.kt b/android_app/app/src/main/java/com/health/openscale/core/data/Enums.kt index 5c319347..5f4ea68d 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/data/Enums.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/data/Enums.kt @@ -399,4 +399,97 @@ enum class ConnectionStatus { DISCONNECTING, /** A connection attempt failed or connection broke unexpectedly. */ FAILED -} \ No newline at end of file +} + +enum class BodyFatFormulaOption { + OFF, + DEURENBERG_1991, + DEURENBERG_1992, + EDDY_1976, + GALLAGHER_2000_NON_ASIAN, + GALLAGHER_2000_ASIAN; + + fun displayName(context: Context) = when (this) { + OFF -> context.getString(R.string.formula_off) + DEURENBERG_1991 -> context.getString(R.string.formula_bf_deurenberg_1991) + DEURENBERG_1992 -> context.getString(R.string.formula_bf_deurenberg_1992) + EDDY_1976 -> context.getString(R.string.formula_bf_eddy_1976) + GALLAGHER_2000_NON_ASIAN -> context.getString(R.string.formula_bf_gallagher_2000_non_asian) + GALLAGHER_2000_ASIAN -> context.getString(R.string.formula_bf_gallagher_2000_asian) + } + + fun shortDescription(ctx: Context) = when (this) { + OFF -> ctx.getString(R.string.formula_desc_off_short) + DEURENBERG_1991 -> ctx.getString(R.string.bf_deurenberg_1991_short) + DEURENBERG_1992 -> ctx.getString(R.string.bf_deurenberg_1992_short) + EDDY_1976 -> ctx.getString(R.string.bf_eddy_1976_short) + GALLAGHER_2000_NON_ASIAN -> ctx.getString(R.string.bf_gallagher_2000_non_asian_short) + GALLAGHER_2000_ASIAN -> ctx.getString(R.string.bf_gallagher_2000_asian_short) + } + fun longDescription(ctx: Context) = when (this) { + OFF -> ctx.getString(R.string.formula_desc_off_long) + DEURENBERG_1991 -> ctx.getString(R.string.bf_deurenberg_1991_long) + DEURENBERG_1992 -> ctx.getString(R.string.bf_deurenberg_1992_long) + EDDY_1976 -> ctx.getString(R.string.bf_eddy_1976_long) + GALLAGHER_2000_NON_ASIAN -> ctx.getString(R.string.bf_gallagher_2000_non_asian_long) + GALLAGHER_2000_ASIAN -> ctx.getString(R.string.bf_gallagher_2000_asian_long) + } +} + +enum class BodyWaterFormulaOption { + OFF, + BEHNKE_1963, + DELWAIDE_CRENIER_1973, + HUME_WEYERS_1971, + LEE_SONG_KIM_2001; + + fun displayName(context: Context) = when (this) { + OFF -> context.getString(R.string.formula_off) + BEHNKE_1963 -> context.getString(R.string.formula_bw_behnke_1963) + DELWAIDE_CRENIER_1973 -> context.getString(R.string.formula_bw_delwaide_crenier_1973) + HUME_WEYERS_1971 -> context.getString(R.string.formula_bw_hume_weyers_1971) + LEE_SONG_KIM_2001 -> context.getString(R.string.formula_bw_lee_song_kim_2001) + } + + fun shortDescription(ctx: Context) = when (this) { + OFF -> ctx.getString(R.string.formula_desc_off_short) + BEHNKE_1963 -> ctx.getString(R.string.bw_behnke_1963_short) + DELWAIDE_CRENIER_1973 -> ctx.getString(R.string.bw_delwaide_crenier_1973_short) + HUME_WEYERS_1971 -> ctx.getString(R.string.bw_hume_weyers_1971_short) + LEE_SONG_KIM_2001 -> ctx.getString(R.string.bw_lee_song_kim_2001_short) + } + fun longDescription(ctx: Context) = when (this) { + OFF -> ctx.getString(R.string.formula_desc_off_long) + BEHNKE_1963 -> ctx.getString(R.string.bw_behnke_1963_long) + DELWAIDE_CRENIER_1973 -> ctx.getString(R.string.bw_delwaide_crenier_1973_long) + HUME_WEYERS_1971 -> ctx.getString(R.string.bw_hume_weyers_1971_long) + LEE_SONG_KIM_2001 -> ctx.getString(R.string.bw_lee_song_kim_2001_long) + } +} + +enum class LbmFormulaOption { + OFF, + BOER_1984, + HUME_1966, + WEIGHT_MINUS_BODY_FAT; + + fun displayName(context: Context) = when (this) { + OFF -> context.getString(R.string.formula_off) + BOER_1984 -> context.getString(R.string.formula_lbm_boer_1984) + HUME_1966 -> context.getString(R.string.formula_lbm_hume_1966) + WEIGHT_MINUS_BODY_FAT -> context.getString(R.string.formula_lbm_weight_minus_body_fat) + } + + fun shortDescription(ctx: Context) = when (this) { + OFF -> ctx.getString(R.string.formula_desc_off_short) + BOER_1984 -> ctx.getString(R.string.lbm_boer_1984_short) + HUME_1966 -> ctx.getString(R.string.lbm_hume_1966_short) + WEIGHT_MINUS_BODY_FAT -> ctx.getString(R.string.lbm_weight_minus_bf_short) + } + fun longDescription(ctx: Context) = when (this) { + OFF -> ctx.getString(R.string.formula_desc_off_long) + BOER_1984 -> ctx.getString(R.string.lbm_boer_1984_long) + HUME_1966 -> ctx.getString(R.string.lbm_hume_1966_long) + WEIGHT_MINUS_BODY_FAT -> ctx.getString(R.string.lbm_weight_minus_bf_long) + } +} diff --git a/android_app/app/src/main/java/com/health/openscale/core/facade/SettingsFacade.kt b/android_app/app/src/main/java/com/health/openscale/core/facade/SettingsFacade.kt index 4b822138..096717ea 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/facade/SettingsFacade.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/facade/SettingsFacade.kt @@ -31,6 +31,9 @@ import androidx.datastore.preferences.core.stringPreferencesKey import androidx.datastore.preferences.core.stringSetPreferencesKey import androidx.datastore.preferences.preferencesDataStore import com.health.openscale.core.data.BackupInterval +import com.health.openscale.core.data.BodyFatFormulaOption +import com.health.openscale.core.data.BodyWaterFormulaOption +import com.health.openscale.core.data.LbmFormulaOption import com.health.openscale.core.data.SmoothingAlgorithm import com.health.openscale.core.utils.LogManager import dagger.Binds @@ -89,6 +92,10 @@ object SettingsPreferenceKeys { val REMINDER_MINUTE = intPreferencesKey("reminder_minute") val REMINDER_DAYS = stringSetPreferencesKey("reminder_days") + val BODY_FAT_FORMULA_OPTION = stringPreferencesKey("body_fat_formula_option") + val BODY_WATER_FORMULA_OPTION = stringPreferencesKey("body_water_formula_option") + val LBM_FORMULA_OPTION = stringPreferencesKey("lbm_formula_option") + // Context strings for screen-specific settings (can be used as prefixes for dynamic keys) const val OVERVIEW_SCREEN_CONTEXT = "overview_screen" const val GRAPH_SCREEN_CONTEXT = "graph_screen" @@ -191,6 +198,15 @@ interface SettingsFacade { val reminderDays: Flow> suspend fun setReminderDays(days: Set) + val selectedBodyFatFormula: Flow + suspend fun setSelectedBodyFatFormula(option: BodyFatFormulaOption) + + val selectedBodyWaterFormula: Flow + suspend fun setSelectedBodyWaterFormula(option: BodyWaterFormulaOption) + + val selectedLbmFormula: Flow + suspend fun setSelectedLbmFormula(option: LbmFormulaOption) + // Generic Settings Accessors /** * Observes a setting with the given key name and default value. @@ -591,6 +607,54 @@ class SettingsFacadeImpl @Inject constructor( saveSetting(SettingsPreferenceKeys.REMINDER_DAYS.name, safe) } + override val selectedBodyFatFormula = dataStore.data + .catch { exception -> + LogManager.e(TAG, "Error reading BODY_FAT_FORMULA_OPTION", exception) + if (exception is IOException) emit(emptyPreferences()) else throw exception + } + .map { prefs -> + val raw = prefs[SettingsPreferenceKeys.BODY_FAT_FORMULA_OPTION] ?: BodyFatFormulaOption.OFF.name + runCatching { BodyFatFormulaOption.valueOf(raw) }.getOrDefault(BodyFatFormulaOption.OFF) + } + .distinctUntilChanged() + + override suspend fun setSelectedBodyFatFormula(option: BodyFatFormulaOption) { + LogManager.d(TAG, "Setting BODY_FAT_FORMULA_OPTION to: ${option.name}") + saveSetting(SettingsPreferenceKeys.BODY_FAT_FORMULA_OPTION.name, option.name) + } + + override val selectedBodyWaterFormula = dataStore.data + .catch { exception -> + LogManager.e(TAG, "Error reading BODY_WATER_FORMULA_OPTION", exception) + if (exception is IOException) emit(emptyPreferences()) else throw exception + } + .map { prefs -> + val raw = prefs[SettingsPreferenceKeys.BODY_WATER_FORMULA_OPTION] ?: BodyWaterFormulaOption.OFF.name + runCatching { BodyWaterFormulaOption.valueOf(raw) }.getOrDefault(BodyWaterFormulaOption.OFF) + } + .distinctUntilChanged() + + override suspend fun setSelectedBodyWaterFormula(option: BodyWaterFormulaOption) { + LogManager.d(TAG, "Setting BODY_WATER_FORMULA_OPTION to: ${option.name}") + saveSetting(SettingsPreferenceKeys.BODY_WATER_FORMULA_OPTION.name, option.name) + } + + override val selectedLbmFormula = dataStore.data + .catch { exception -> + LogManager.e(TAG, "Error reading LBM_FORMULA_OPTION", exception) + if (exception is IOException) emit(emptyPreferences()) else throw exception + } + .map { prefs -> + val raw = prefs[SettingsPreferenceKeys.LBM_FORMULA_OPTION] ?: LbmFormulaOption.OFF.name + runCatching { LbmFormulaOption.valueOf(raw) }.getOrDefault(LbmFormulaOption.OFF) + } + .distinctUntilChanged() + + override suspend fun setSelectedLbmFormula(option: LbmFormulaOption) { + LogManager.d(TAG, "Setting LBM_FORMULA_OPTION to: ${option.name}") + saveSetting(SettingsPreferenceKeys.LBM_FORMULA_OPTION.name, option.name) + } + @Suppress("UNCHECKED_CAST") override fun observeSetting(keyName: String, defaultValue: T): Flow { LogManager.v(TAG, "Observing setting: key='$keyName', type='${defaultValue!!::class.simpleName}'") diff --git a/android_app/app/src/main/java/com/health/openscale/core/usecase/BodyCompositionUseCases.kt b/android_app/app/src/main/java/com/health/openscale/core/usecase/BodyCompositionUseCases.kt new file mode 100644 index 00000000..a1ae50c6 --- /dev/null +++ b/android_app/app/src/main/java/com/health/openscale/core/usecase/BodyCompositionUseCases.kt @@ -0,0 +1,215 @@ +/* + * openScale + * Copyright (C) 2025 olie.xdev + * + * 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.health.openscale.core.usecase + +import com.health.openscale.core.data.* +import com.health.openscale.core.facade.SettingsFacade +import com.health.openscale.core.utils.CalculationUtils +import com.health.openscale.core.utils.ConverterUtils +import kotlinx.coroutines.flow.first +import javax.inject.Inject +import javax.inject.Singleton +import kotlin.math.pow + +@Singleton +class BodyCompositionUseCases @Inject constructor( + private val settings: SettingsFacade, + private val userUseCases: UserUseCases, + private val measurementQuery: MeasurementQueryUseCases +) { + /** + * Apply selected body-composition formulas for the given measurement. + * + * Rules: + * - If a formula is OFF → leave values untouched (no overwrite, no insert). + * - If a formula is ON → overwrite existing value; insert a new value if missing. + * - If computed result is null → do not insert; keep existing value as-is. + * + * No DB I/O here; caller persists via CRUD. + */ + suspend fun applySelectedFormulasForMeasurement( + measurement: Measurement, + values: List + ): List { + val bfOpt = settings.selectedBodyFatFormula.first() + val bwOpt = settings.selectedBodyWaterFormula.first() + val lbmOpt = settings.selectedLbmFormula.first() + + if (bfOpt == BodyFatFormulaOption.OFF && + bwOpt == BodyWaterFormulaOption.OFF && + lbmOpt == LbmFormulaOption.OFF + ) return values + + val user = userUseCases.observeUserById(measurement.userId).first() ?: return values + val types = measurementQuery.getAllMeasurementTypes().first() + val byKey = types.associateBy { it.key } + + // Anchor: weight (in kg) + val weightType = byKey[MeasurementTypeKey.WEIGHT] ?: return values + val weightVal = values.find { it.typeId == weightType.id }?.floatValue ?: return values + val weightKg = when (weightType.unit) { + UnitType.KG -> weightVal + UnitType.LB -> ConverterUtils.toKilogram(weightVal, WeightUnit.LB) + UnitType.ST -> ConverterUtils.toKilogram(weightVal, WeightUnit.ST) + else -> return values + } + + val heightCm = user.heightCm.toDouble().takeIf { it > 0.0 } ?: return values + val ageYears = CalculationUtils.ageOn(measurement.timestamp, user.birthDate) + val isMale = user.gender == GenderType.MALE + + // BMI (prefer provided, otherwise compute) + val bmiType = byKey[MeasurementTypeKey.BMI] + val bmiProvided = bmiType?.let { t -> values.find { it.typeId == t.id }?.floatValue } + val bmi = bmiProvided?.toDouble() ?: run { + val hM = heightCm / 100.0 + weightKg / (hM.pow(2.0)) + } + + val out = values.toMutableList() + + // Helper: overwrite existing or insert new value if missing (only if newValue != null) + fun upsertFloat(type: MeasurementType, newValue: Float?) { + val idx = out.indexOfFirst { it.typeId == type.id } + if (idx >= 0) { + // Update existing (can set to null) + out[idx] = out[idx].copy(floatValue = newValue) + } else if (newValue != null) { + // Insert new row only when we have a concrete value + out.add( + MeasurementValue( + id = 0, + measurementId = measurement.id, // 0 for new; CRUD layer will set final id + typeId = type.id, + floatValue = newValue + ) + ) + } + } + + // --- BODY FAT (%) + byKey[MeasurementTypeKey.BODY_FAT]?.let { type -> + if (bfOpt != BodyFatFormulaOption.OFF) { + val bf = when (bfOpt) { + BodyFatFormulaOption.DEURENBERG_1991 -> + (1.2 * bmi) + (0.23 * ageYears) - if (isMale) 16.2 else 5.4 + BodyFatFormulaOption.DEURENBERG_1992 -> + if (ageYears >= 16) + (1.2 * bmi) + (0.23 * ageYears) - (10.8 * (if (isMale) 1 else 0)) - 5.4 + else + (1.294 * bmi) + (0.20 * ageYears) - (11.4 * (if (isMale) 1 else 0)) - 8.0 + BodyFatFormulaOption.EDDY_1976 -> + if (isMale) (1.281 * bmi) - 10.13 else (1.48 * bmi) - 7.0 + BodyFatFormulaOption.GALLAGHER_2000_NON_ASIAN -> + 64.5 - (848.0 / bmi) + (0.079 * ageYears) + BodyFatFormulaOption.GALLAGHER_2000_ASIAN -> + if (isMale) 51.9 - (740.0 / bmi) + (0.029 * ageYears) + else 64.8 - (752.0 / bmi) + (0.016 * ageYears) + BodyFatFormulaOption.OFF -> null + }?.coerceAtLeast(0.0) + + val newVal = bf?.toFloat() + ?.coerceIn(0f, 75f) + ?.let { CalculationUtils.roundTo(it) } + + upsertFloat(type, newVal) + } + } + + // --- BODY WATER (stored either as %, or mass depending on type.unit) + byKey[MeasurementTypeKey.WATER]?.let { type -> + if (bwOpt != BodyWaterFormulaOption.OFF) { + val liters = when (bwOpt) { + BodyWaterFormulaOption.BEHNKE_1963 -> + 0.72 * ((if (isMale) 0.204 else 0.18) * (heightCm * heightCm)) / 100.0 + BodyWaterFormulaOption.DELWAIDE_CRENIER_1973 -> + 0.72 * (-1.976 + 0.907 * weightKg) + BodyWaterFormulaOption.HUME_WEYERS_1971 -> + if (isMale) (0.194786 * heightCm) + (0.296785 * weightKg) - 14.012934 + else (0.34454 * heightCm) + (0.183809 * weightKg) - 35.270121 + BodyWaterFormulaOption.LEE_SONG_KIM_2001 -> + if (isMale) -28.3497 + (0.243057 * heightCm) + (0.366248 * weightKg) + else -26.6224 + (0.262513 * heightCm) + (0.232948 * weightKg) + BodyWaterFormulaOption.OFF -> null + }?.coerceAtLeast(0.0) + + val newVal: Float? = liters?.let { + when (type.unit) { + UnitType.PERCENT -> { + val pct = (it / weightKg) * 100.0 + CalculationUtils.roundTo(pct.toFloat().coerceIn(0f, 100f)) + } + UnitType.KG -> CalculationUtils.roundTo(it.toFloat().coerceAtLeast(0f)) + UnitType.LB -> CalculationUtils.roundTo( + ConverterUtils.fromKilogram(it.toFloat(), WeightUnit.LB) + ) + UnitType.ST -> CalculationUtils.roundTo( + ConverterUtils.fromKilogram(it.toFloat(), WeightUnit.ST) + ) + else -> { + // Default to percent if unit is unexpected + val pct = (it / weightKg) * 100.0 + CalculationUtils.roundTo(pct.toFloat().coerceIn(0f, 100f)) + } + } + } + + upsertFloat(type, newVal) + } + } + + // --- LBM (mass) + byKey[MeasurementTypeKey.LBM]?.let { type -> + if (lbmOpt != LbmFormulaOption.OFF) { + val lbmKg = when (lbmOpt) { + LbmFormulaOption.WEIGHT_MINUS_BODY_FAT -> { + val bfType = byKey[MeasurementTypeKey.BODY_FAT] + // Prefer freshly computed BF in 'out', fallback to original list + val bfPercent = out.firstOrNull { it.typeId == bfType?.id }?.floatValue + ?: values.firstOrNull { it.typeId == bfType?.id }?.floatValue + bfPercent?.let { weightKg * (1f - it / 100f) }?.toDouble() + } + LbmFormulaOption.BOER_1984 -> + if (isMale) (0.4071 * weightKg) + (0.267 * heightCm) - 19.2 + else (0.252 * weightKg) + (0.473 * heightCm) - 48.3 + LbmFormulaOption.HUME_1966 -> + if (isMale) (0.32810 * weightKg) + (0.33929 * heightCm) - 29.5336 + else (0.29569 * weightKg) + (0.41813 * heightCm) - 43.2933 + LbmFormulaOption.OFF -> null + }?.coerceAtLeast(0.0) + + val newVal = lbmKg?.let { + when (type.unit) { + UnitType.KG -> CalculationUtils.roundTo(it.toFloat()) + UnitType.LB -> CalculationUtils.roundTo( + ConverterUtils.fromKilogram(it.toFloat(), WeightUnit.LB) + ) + UnitType.ST -> CalculationUtils.roundTo( + ConverterUtils.fromKilogram(it.toFloat(), WeightUnit.ST) + ) + else -> CalculationUtils.roundTo(it.toFloat()) + } + } + + upsertFloat(type, newVal) + } + } + + return out + } +} diff --git a/android_app/app/src/main/java/com/health/openscale/core/usecase/MeasurementCrudUseCases.kt b/android_app/app/src/main/java/com/health/openscale/core/usecase/MeasurementCrudUseCases.kt index 571a9a86..2ea86009 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/usecase/MeasurementCrudUseCases.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/usecase/MeasurementCrudUseCases.kt @@ -44,10 +44,11 @@ import javax.inject.Singleton @Singleton class MeasurementCrudUseCases @Inject constructor( @ApplicationContext private val appContext: Context, - private val databaseRepository: DatabaseRepository, + private val settingsFacade: SettingsFacade, private val sync: SyncUseCases, - private val settingsFacade: SettingsFacade -) { + private val bodyComposition: BodyCompositionUseCases, + private val databaseRepository: DatabaseRepository + ) { private var lastVibrateTime = 0L /** @@ -67,17 +68,19 @@ class MeasurementCrudUseCases @Inject constructor( measurement: Measurement, values: List ): Result = runCatching { + val finalValues : List = bodyComposition.applySelectedFormulasForMeasurement(measurement, values) + if (measurement.id == 0) { // Insert path val newId = databaseRepository.insertMeasurement(measurement).toInt() - values.forEach { v -> + finalValues.forEach { v -> databaseRepository.insertMeasurementValue(v.copy(measurementId = newId)) } - sync.triggerSyncInsert(measurement, values,"com.health.openscale.sync") - sync.triggerSyncInsert(measurement, values,"com.health.openscale.sync.oss") - sync.triggerSyncInsert(measurement, values,"com.health.openscale.sync.debug") + sync.triggerSyncInsert(measurement, finalValues,"com.health.openscale.sync") + sync.triggerSyncInsert(measurement, finalValues,"com.health.openscale.sync.oss") + sync.triggerSyncInsert(measurement, finalValues,"com.health.openscale.sync.debug") MeasurementWidget.refreshAll(appContext) @@ -89,7 +92,7 @@ class MeasurementCrudUseCases @Inject constructor( databaseRepository.updateMeasurement(measurement) val existing = databaseRepository.getValuesForMeasurement(measurement.id).first() - val newSetIds = values.mapNotNull { if (it.id != 0) it.id else null }.toSet() + val newSetIds = finalValues.mapNotNull { if (it.id != 0) it.id else null }.toSet() val existingIds = existing.map { it.id }.toSet() // Delete removed values @@ -97,7 +100,7 @@ class MeasurementCrudUseCases @Inject constructor( toDelete.forEach { id -> databaseRepository.deleteMeasurementValueById(id) } // Update or insert values - values.forEach { v -> + finalValues.forEach { v -> val exists = existing.any { it.id == v.id && v.id != 0 } if (exists) { databaseRepository.updateMeasurementValue(v.copy(measurementId = measurement.id)) @@ -106,9 +109,9 @@ class MeasurementCrudUseCases @Inject constructor( } } - sync.triggerSyncUpdate(measurement, values, "com.health.openscale.sync") - sync.triggerSyncUpdate(measurement, values,"com.health.openscale.sync.oss") - sync.triggerSyncUpdate(measurement, values,"com.health.openscale.sync.debug") + sync.triggerSyncUpdate(measurement, finalValues, "com.health.openscale.sync") + sync.triggerSyncUpdate(measurement, finalValues,"com.health.openscale.sync.oss") + sync.triggerSyncUpdate(measurement, finalValues,"com.health.openscale.sync.debug") MeasurementWidget.refreshAll(appContext) diff --git a/android_app/app/src/main/java/com/health/openscale/ui/screen/settings/MeasurementTypeDetailScreen.kt b/android_app/app/src/main/java/com/health/openscale/ui/screen/settings/MeasurementTypeDetailScreen.kt index 4e641785..c1a8e4bd 100644 --- a/android_app/app/src/main/java/com/health/openscale/ui/screen/settings/MeasurementTypeDetailScreen.kt +++ b/android_app/app/src/main/java/com/health/openscale/ui/screen/settings/MeasurementTypeDetailScreen.kt @@ -17,29 +17,27 @@ */ package com.health.openscale.ui.screen.settings -import android.R.attr.enabled -import android.R.attr.label import android.widget.Toast import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.background -import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.IntrinsicSize import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.QuestionMark import androidx.compose.material.icons.filled.Save +import androidx.compose.material.icons.outlined.Info +import androidx.compose.material.icons.outlined.Warning import androidx.compose.material3.AlertDialog +import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExposedDropdownMenuBox @@ -62,20 +60,22 @@ import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue 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.graphics.painter.Painter import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.navigation.NavController import com.health.openscale.R +import com.health.openscale.core.data.BodyFatFormulaOption +import com.health.openscale.core.data.BodyWaterFormulaOption import com.health.openscale.core.data.InputFieldType +import com.health.openscale.core.data.LbmFormulaOption import com.health.openscale.core.data.MeasurementType import com.health.openscale.core.data.MeasurementTypeIcon import com.health.openscale.core.data.MeasurementTypeKey @@ -85,8 +85,16 @@ import com.health.openscale.ui.shared.SharedViewModel import com.health.openscale.ui.screen.dialog.ColorPickerDialog import com.health.openscale.ui.screen.dialog.IconPickerDialog import com.health.openscale.ui.shared.TopBarAction +import kotlinx.coroutines.launch import kotlin.text.lowercase +private sealed interface PendingDialog { + data class UnitChange(val from: UnitType, val to: UnitType) : PendingDialog + data class FormulaOnBodyFat(val option: BodyFatFormulaOption) : PendingDialog + data class FormulaOnBodyWater(val option: BodyWaterFormulaOption) : PendingDialog + data class FormulaOnLBM(val option: LbmFormulaOption) : PendingDialog +} + @OptIn(ExperimentalMaterial3Api::class) @Composable fun MeasurementTypeDetailScreen( @@ -96,51 +104,29 @@ fun MeasurementTypeDetailScreen( settingsViewModel: SettingsViewModel, ) { val context = LocalContext.current + val scope = rememberCoroutineScope() val measurementTypes by sharedViewModel.measurementTypes.collectAsState() - // Stores the original state of the measurement type before any UI changes. - // Crucial for the conversion logic to have the true original state. val originalExistingType = remember(measurementTypes, typeId) { measurementTypes.find { it.id == typeId } } val isEdit = typeId != -1 - // Determine the MeasurementTypeKey for the allowed units logic. - // For new types, it's always CUSTOM; for existing types, it's the type's key. val currentMeasurementTypeKey = remember(originalExistingType, isEdit) { - if (isEdit) originalExistingType?.key ?: MeasurementTypeKey.CUSTOM - else MeasurementTypeKey.CUSTOM + if (isEdit) originalExistingType?.key ?: MeasurementTypeKey.CUSTOM else MeasurementTypeKey.CUSTOM } - // Get the list of allowed units based on the key. - val allowedUnitsForKey = remember(currentMeasurementTypeKey) { - currentMeasurementTypeKey.allowedUnitTypes - } - - val allowedInputTypesForKey = remember(currentMeasurementTypeKey) { - currentMeasurementTypeKey.allowedInputType - } + val allowedUnitsForKey = remember(currentMeasurementTypeKey) { currentMeasurementTypeKey.allowedUnitTypes } + val allowedInputTypesForKey = remember(currentMeasurementTypeKey) { currentMeasurementTypeKey.allowedInputType } var name by remember { mutableStateOf(originalExistingType?.getDisplayName(context).orEmpty()) } - - // Safely set selectedUnit. If the existing unit isn't allowed or if no existing unit, - // use the first allowed unit. var selectedUnit by remember { - val initialUnit = originalExistingType?.unit - if (initialUnit != null && initialUnit in allowedUnitsForKey) { - mutableStateOf(initialUnit) - } else { - mutableStateOf(allowedUnitsForKey.firstOrNull() ?: UnitType.NONE) - } + val u = originalExistingType?.unit + mutableStateOf(if (u != null && u in allowedUnitsForKey) u else allowedUnitsForKey.firstOrNull() ?: UnitType.NONE) } - var selectedInputType by remember { - val initialInputType = originalExistingType?.inputType - if (initialInputType != null && initialInputType in allowedInputTypesForKey) { - mutableStateOf(initialInputType) - } else { - mutableStateOf(allowedInputTypesForKey.firstOrNull() ?: InputFieldType.FLOAT) - } + val itp = originalExistingType?.inputType + mutableStateOf(if (itp != null && itp in allowedInputTypesForKey) itp else allowedInputTypesForKey.firstOrNull() ?: InputFieldType.FLOAT) } var selectedColor by remember { mutableStateOf(originalExistingType?.color ?: 0xFFFFA726.toInt()) } var selectedIcon by remember { mutableStateOf(originalExistingType?.icon ?: MeasurementTypeIcon.IC_DEFAULT) } @@ -148,140 +134,220 @@ fun MeasurementTypeDetailScreen( var isPinned by remember { mutableStateOf(originalExistingType?.isPinned ?: false) } var isOnRightYAxis by remember { mutableStateOf(originalExistingType?.isOnRightYAxis ?: false) } + val bodyFatFormulaOption by sharedViewModel.selectedBodyFatFormula.collectAsState(BodyFatFormulaOption.OFF) + val bodyWaterFormulaOption by sharedViewModel.selectedBodyWaterFormula.collectAsState(BodyWaterFormulaOption.OFF) + val lbmFormulaOption by sharedViewModel.selectedLbmFormula.collectAsState(LbmFormulaOption.OFF) + + var bodyFatFormula by remember(bodyFatFormulaOption) { mutableStateOf(bodyFatFormulaOption) } + var bodyWaterFormula by remember(bodyWaterFormulaOption) { mutableStateOf(bodyWaterFormulaOption) } + var lbmFormula by remember(lbmFormulaOption) { mutableStateOf(lbmFormulaOption) } + + var formulaInfoTitle by remember { mutableStateOf(null) } + var formulaInfoText by remember { mutableStateOf(null) } + + var pendingDialog by remember { mutableStateOf(null) } + var expandedUnit by remember { mutableStateOf(false) } var expandedInputType by remember { mutableStateOf(false) } var showColorPicker by remember { mutableStateOf(false) } var showIconPicker by remember { mutableStateOf(false) } - var showConfirmDialog by remember { mutableStateOf(false) } - var pendingUpdatedType by remember { mutableStateOf(null) } - val titleEdit = stringResource(R.string.measurement_type_detail_title_edit) val titleAdd = stringResource(R.string.measurement_type_detail_title_add) - val unitDropdownEnabled by remember(allowedUnitsForKey) { - derivedStateOf { allowedUnitsForKey.size > 1 } - } - val inputTypeDropdownEnabled by remember(allowedInputTypesForKey) { - derivedStateOf { allowedInputTypesForKey.size > 1 } - } + val unitDropdownEnabled by remember(allowedUnitsForKey) { derivedStateOf { allowedUnitsForKey.size > 1 } } + val inputTypeDropdownEnabled by remember(allowedInputTypesForKey) { derivedStateOf { allowedInputTypesForKey.size > 1 } } LaunchedEffect(originalExistingType, allowedUnitsForKey) { - val currentUnitInExistingType = originalExistingType?.unit - if (currentUnitInExistingType != null && currentUnitInExistingType in allowedUnitsForKey) { - if (selectedUnit != currentUnitInExistingType) { // Only update if different to avoid recomposition loops - selectedUnit = currentUnitInExistingType - } - } else if (allowedUnitsForKey.isNotEmpty() && selectedUnit !in allowedUnitsForKey) { - selectedUnit = allowedUnitsForKey.first() - } else if (allowedUnitsForKey.isEmpty() && selectedUnit != UnitType.NONE) { - // This case should ideally not be reached if keys are well-defined. - selectedUnit = UnitType.NONE - } + originalExistingType?.unit?.let { if (it in allowedUnitsForKey && it != selectedUnit) selectedUnit = it } + if (allowedUnitsForKey.isNotEmpty() && selectedUnit !in allowedUnitsForKey) selectedUnit = allowedUnitsForKey.first() + if (allowedUnitsForKey.isEmpty() && selectedUnit != UnitType.NONE) selectedUnit = UnitType.NONE } - LaunchedEffect(originalExistingType, allowedInputTypesForKey) { - val currentInputTypeInExistingType = originalExistingType?.inputType - if (currentInputTypeInExistingType != null && currentInputTypeInExistingType in allowedInputTypesForKey) { - if (selectedInputType != currentInputTypeInExistingType) { - selectedInputType = currentInputTypeInExistingType - } - } else if (allowedInputTypesForKey.isNotEmpty() && selectedInputType !in allowedInputTypesForKey) { - selectedInputType = allowedInputTypesForKey.first() - } else if (allowedInputTypesForKey.isEmpty()) { - selectedInputType = InputFieldType.FLOAT - } + originalExistingType?.inputType?.let { if (it in allowedInputTypesForKey && it != selectedInputType) selectedInputType = it } + if (allowedInputTypesForKey.isNotEmpty() && selectedInputType !in allowedInputTypesForKey) selectedInputType = allowedInputTypesForKey.first() + if (allowedInputTypesForKey.isEmpty()) selectedInputType = InputFieldType.FLOAT } LaunchedEffect(Unit) { sharedViewModel.setTopBarTitle(if (isEdit) titleEdit else titleAdd) sharedViewModel.setTopBarAction( TopBarAction(icon = Icons.Default.Save, onClick = { - if (name.isNotBlank()) { - // When creating the updatedType, use the key of the originalExistingType if it's an edit. - // For new types, it's MeasurementTypeKey.CUSTOM. - val finalKey = if (isEdit) originalExistingType?.key ?: MeasurementTypeKey.CUSTOM else MeasurementTypeKey.CUSTOM + if (name.isBlank()) { + Toast.makeText(context, R.string.toast_enter_valid_data, Toast.LENGTH_SHORT).show() + return@TopBarAction + } - val currentUpdatedType = MeasurementType( - id = originalExistingType?.id ?: 0, - name = name, - icon = selectedIcon, - color = selectedColor, - unit = selectedUnit, - inputType = selectedInputType, - displayOrder = originalExistingType?.displayOrder ?: measurementTypes.size, - isEnabled = isEnabled, - isPinned = isPinned, - key = finalKey, // Use the correct key - isDerived = originalExistingType?.isDerived ?: false, - isOnRightYAxis = isOnRightYAxis - ) + val finalKey = if (isEdit) originalExistingType?.key ?: MeasurementTypeKey.CUSTOM else MeasurementTypeKey.CUSTOM - if (isEdit && originalExistingType != null) { - val unitChanged = originalExistingType.unit != currentUpdatedType.unit - val inputTypesAreFloat = - originalExistingType.inputType == InputFieldType.FLOAT && currentUpdatedType.inputType == InputFieldType.FLOAT + val derivedForType = when (currentMeasurementTypeKey) { + MeasurementTypeKey.BODY_FAT -> bodyFatFormula != BodyFatFormulaOption.OFF + MeasurementTypeKey.WATER -> bodyWaterFormula != BodyWaterFormulaOption.OFF + MeasurementTypeKey.LBM -> lbmFormula != LbmFormulaOption.OFF + else -> originalExistingType?.isDerived ?: false + } - if (unitChanged && inputTypesAreFloat) { - pendingUpdatedType = currentUpdatedType - showConfirmDialog = true - } else { - settingsViewModel.updateMeasurementType(currentUpdatedType) - navController.popBackStack() - } - } else { - settingsViewModel.addMeasurementType(currentUpdatedType) - navController.popBackStack() + val updatedType = MeasurementType( + id = originalExistingType?.id ?: 0, + name = name, + icon = selectedIcon, + color = selectedColor, + unit = selectedUnit, + inputType = selectedInputType, + displayOrder = originalExistingType?.displayOrder ?: measurementTypes.size, + isEnabled = isEnabled, + isPinned = isPinned, + key = finalKey, + isDerived = derivedForType, + isOnRightYAxis = isOnRightYAxis + ) + + scope.launch { + when (currentMeasurementTypeKey) { + MeasurementTypeKey.BODY_FAT -> if (bodyFatFormula != bodyFatFormulaOption) sharedViewModel.setSelectedBodyFatFormula(bodyFatFormula) + MeasurementTypeKey.WATER -> if (bodyWaterFormula != bodyWaterFormulaOption) sharedViewModel.setSelectedBodyWaterFormula(bodyWaterFormula) + MeasurementTypeKey.LBM -> if (lbmFormula != lbmFormulaOption) sharedViewModel.setSelectedLbmFormula(lbmFormula) + else -> Unit } + } + + val unitChanged = isEdit && (originalExistingType?.unit != selectedUnit) + val needsConversion = + unitChanged && + (originalExistingType?.inputType == InputFieldType.FLOAT) && + (selectedInputType == InputFieldType.FLOAT) + + if (isEdit && originalExistingType != null) { + if (needsConversion) { + settingsViewModel.updateMeasurementTypeWithConversion( + originalType = originalExistingType, + updatedType = updatedType + ) + } else { + settingsViewModel.updateMeasurementType(updatedType) + } + navController.popBackStack() } else { - Toast.makeText(context, R.string.toast_enter_valid_data, Toast.LENGTH_SHORT) - .show() + settingsViewModel.addMeasurementType(updatedType) + navController.popBackStack() } }) ) } - if (showConfirmDialog && originalExistingType != null && pendingUpdatedType != null) { + fun requestFormulaChange(newValue: Any) { + val turningOn = when (newValue) { + is BodyFatFormulaOption -> newValue != BodyFatFormulaOption.OFF + is BodyWaterFormulaOption -> newValue != BodyWaterFormulaOption.OFF + is LbmFormulaOption -> newValue != LbmFormulaOption.OFF + else -> false + } + if (turningOn) { + pendingDialog = when (newValue) { + is BodyFatFormulaOption -> PendingDialog.FormulaOnBodyFat(newValue) + is BodyWaterFormulaOption -> PendingDialog.FormulaOnBodyWater(newValue) + is LbmFormulaOption -> PendingDialog.FormulaOnLBM(newValue) + else -> null + } + } else { + when (newValue) { + is BodyFatFormulaOption -> { bodyFatFormula = newValue; } + is BodyWaterFormulaOption -> { bodyWaterFormula = newValue; } + is LbmFormulaOption -> { lbmFormula = newValue; } + } + } + } + + pendingDialog?.let { dlg -> + val titleId = when (dlg) { + is PendingDialog.UnitChange -> R.string.measurement_type_dialog_confirm_unit_change_title + is PendingDialog.FormulaOnBodyFat, + is PendingDialog.FormulaOnBodyWater, + is PendingDialog.FormulaOnLBM -> R.string.formula_warning_title + } + + val message = when (dlg) { + is PendingDialog.UnitChange -> { + val typeName = (originalExistingType?.getDisplayName(context) ?: name) + val fromName = selectedUnit.displayName.lowercase().replaceFirstChar { it.uppercase() } + val toName = dlg.to.displayName.lowercase().replaceFirstChar { it.uppercase() } + context.getString( + R.string.measurement_type_dialog_confirm_unit_change_message, + typeName, fromName, toName + ) + } + is PendingDialog.FormulaOnBodyFat, + is PendingDialog.FormulaOnBodyWater, + is PendingDialog.FormulaOnLBM -> { + context.getString(R.string.formula_warning_message) + } + } + AlertDialog( - onDismissRequest = { showConfirmDialog = false }, - title = { Text(stringResource(R.string.measurement_type_dialog_confirm_unit_change_title)) }, + onDismissRequest = { pendingDialog = null }, + icon= { + Icon( + imageVector = Icons.Outlined.Warning, + contentDescription = null, + tint = MaterialTheme.colorScheme.error, + modifier = Modifier.size(20.dp) + ) + }, + title = { + Text( + text = stringResource(titleId), + style = MaterialTheme.typography.titleLarge + ) + }, text = { Text( - stringResource( - R.string.measurement_type_dialog_confirm_unit_change_message, - originalExistingType.getDisplayName(context), - originalExistingType.unit.displayName.lowercase().replaceFirstChar { it.uppercase() }, - pendingUpdatedType!!.unit.displayName.lowercase().replaceFirstChar { it.uppercase() } - ) + text = message, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant ) }, confirmButton = { - TextButton(onClick = { - settingsViewModel.updateMeasurementTypeWithConversion( - originalType = originalExistingType, - updatedType = pendingUpdatedType!! + TextButton( + onClick = { + when (dlg) { + is PendingDialog.UnitChange -> { + selectedUnit = dlg.to + } + is PendingDialog.FormulaOnBodyFat -> { + bodyFatFormula = dlg.option + } + is PendingDialog.FormulaOnBodyWater -> { + bodyWaterFormula = dlg.option + } + is PendingDialog.FormulaOnLBM -> { + lbmFormula = dlg.option + } + } + pendingDialog = null + }, + colors = ButtonDefaults.textButtonColors( + contentColor = MaterialTheme.colorScheme.error ) - showConfirmDialog = false - navController.popBackStack() - }) { Text(stringResource(R.string.confirm_button)) } + ) { + Text(stringResource(R.string.confirm_button)) + } }, dismissButton = { - TextButton(onClick = { showConfirmDialog = false }) { Text(stringResource(R.string.cancel_button)) } + TextButton(onClick = { pendingDialog = null }) { + Text(stringResource(R.string.cancel_button)) + } } ) } + + Column( - modifier = Modifier - .padding(16.dp) - .fillMaxSize(), + modifier = Modifier.padding(16.dp).fillMaxSize(), verticalArrangement = Arrangement.spacedBy(16.dp) ) { OutlinedSettingRow(label = stringResource(R.string.measurement_type_label_enabled)) { - Switch( - checked = isEnabled, - onCheckedChange = { isEnabled = it } - ) + Switch(checked = isEnabled, onCheckedChange = { isEnabled = it }) } if (!isEdit || (originalExistingType?.key == MeasurementTypeKey.CUSTOM)) { @@ -295,20 +361,13 @@ fun MeasurementTypeDetailScreen( OutlinedTextField( value = "", - onValueChange = {}, // Read-only + onValueChange = {}, label = { Text(stringResource(R.string.measurement_type_label_color)) }, - modifier = Modifier - .fillMaxWidth() - .clickable { showColorPicker = true }, + modifier = Modifier.fillMaxWidth().clickable { showColorPicker = true }, readOnly = true, - enabled = false, // To make it look like a display field that's clickable + enabled = false, trailingIcon = { - Box( - modifier = Modifier - .size(24.dp) - .clip(CircleShape) - .background(Color(selectedColor)) - ) + Box(modifier = Modifier.size(24.dp).clip(CircleShape).background(Color(selectedColor))) }, colors = TextFieldDefaults.colors( disabledTextColor = LocalContentColor.current, @@ -321,18 +380,12 @@ fun MeasurementTypeDetailScreen( OutlinedTextField( value = "", - onValueChange = {}, // Read-only + onValueChange = {}, label = { Text(stringResource(R.string.measurement_type_label_icon)) }, - modifier = Modifier - .fillMaxWidth() - .clickable { showIconPicker = true }, + modifier = Modifier.fillMaxWidth().clickable { showIconPicker = true }, readOnly = true, - enabled = false, // To make it look like a display field - trailingIcon = { - MeasurementIcon( - icon = selectedIcon, - ) - }, + enabled = false, + trailingIcon = { MeasurementIcon(icon = selectedIcon) }, colors = TextFieldDefaults.colors( disabledTextColor = LocalContentColor.current, disabledIndicatorColor = MaterialTheme.colorScheme.outline, @@ -342,24 +395,64 @@ fun MeasurementTypeDetailScreen( ) ) + // Formula pickers use local state + when (currentMeasurementTypeKey) { + MeasurementTypeKey.BODY_FAT -> { + FormulaPickerRow( + label = stringResource(R.string.formula_label_body_fat), + currentText = bodyFatFormula.displayName(context), + options = BodyFatFormulaOption.entries, + optionLabel = { it.displayName(context) }, + optionSubtitle = { it.shortDescription(context) }, + onInfo = { opt -> + formulaInfoTitle = opt.displayName(context) + formulaInfoText = opt.longDescription(context) + }, + onSelect = { requestFormulaChange( it) } + ) + } + MeasurementTypeKey.WATER -> { + FormulaPickerRow( + label = stringResource(R.string.formula_label_body_water), + currentText = bodyWaterFormula.displayName(context), + options = BodyWaterFormulaOption.entries, + optionLabel = { it.displayName(context) }, + optionSubtitle = { it.shortDescription(context) }, + onInfo = { opt -> + formulaInfoTitle = opt.displayName(context) + formulaInfoText = opt.longDescription(context) + }, + onSelect = { requestFormulaChange(it) } + ) + } + MeasurementTypeKey.LBM -> { + FormulaPickerRow( + label = stringResource(R.string.formula_label_lbm), + currentText = lbmFormula.displayName(context), + options = LbmFormulaOption.entries, + optionLabel = { it.displayName(context) }, + optionSubtitle = { it.shortDescription(context) }, + onInfo = { opt -> + formulaInfoTitle = opt.displayName(context) + formulaInfoText = opt.longDescription(context) + }, + onSelect = { requestFormulaChange(it) } + ) + } + else -> Unit + } + if (unitDropdownEnabled) { ExposedDropdownMenuBox( expanded = expandedUnit && unitDropdownEnabled, - onExpandedChange = { - if (unitDropdownEnabled) expandedUnit = !expandedUnit - }, + onExpandedChange = { if (unitDropdownEnabled) expandedUnit = !expandedUnit }, modifier = Modifier.fillMaxWidth() ) { OutlinedSettingRow( label = stringResource(R.string.measurement_type_label_unit), surfaceModifier = Modifier - .menuAnchor( - type = MenuAnchorType.PrimaryNotEditable, - enabled = unitDropdownEnabled - ) - .clickable(enabled = unitDropdownEnabled) { - if (unitDropdownEnabled) expandedUnit = true - }, + .menuAnchor(type = MenuAnchorType.PrimaryNotEditable, enabled = unitDropdownEnabled) + .clickable(enabled = unitDropdownEnabled) { if (unitDropdownEnabled) expandedUnit = true }, controlContent = { Row(verticalAlignment = Alignment.CenterVertically) { Text( @@ -367,13 +460,10 @@ fun MeasurementTypeDetailScreen( style = MaterialTheme.typography.bodyLarge, color = if (unitDropdownEnabled) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f) ) - if (unitDropdownEnabled) { - ExposedDropdownMenuDefaults.TrailingIcon(expanded = expandedUnit) - } + if (unitDropdownEnabled) ExposedDropdownMenuDefaults.TrailingIcon(expanded = expandedUnit) } } ) - if (unitDropdownEnabled) { ExposedDropdownMenu( expanded = expandedUnit, @@ -383,19 +473,24 @@ fun MeasurementTypeDetailScreen( allowedUnitsForKey.forEach { unit -> DropdownMenuItem( text = { - Box( - modifier = Modifier.fillMaxWidth(), - contentAlignment = Alignment.CenterEnd - ) { - Text( - text = unit.displayName, - modifier = Modifier.padding(end = 32.dp) - ) + Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.CenterEnd) { + Text(unit.displayName, modifier = Modifier.padding(end = 32.dp)) } }, onClick = { - selectedUnit = unit expandedUnit = false + if (unit == selectedUnit) return@DropdownMenuItem + + val needsConfirm = + isEdit && + (originalExistingType?.inputType == InputFieldType.FLOAT) && + (selectedInputType == InputFieldType.FLOAT) + + if (needsConfirm) { + pendingDialog = PendingDialog.UnitChange(from = selectedUnit, to = unit) + } else { + selectedUnit = unit + } } ) } @@ -404,36 +499,21 @@ fun MeasurementTypeDetailScreen( } } - // InputFieldType Dropdown if (inputTypeDropdownEnabled) { - ExposedDropdownMenuBox( - expanded = expandedInputType, - onExpandedChange = { expandedInputType = !expandedInputType } - ) { + ExposedDropdownMenuBox(expanded = expandedInputType, onExpandedChange = { expandedInputType = !expandedInputType }) { OutlinedTextField( readOnly = true, value = selectedInputType.name.lowercase().replaceFirstChar { it.uppercase() }, onValueChange = {}, label = { Text(stringResource(R.string.measurement_type_label_input_type)) }, trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expandedInputType) }, - modifier = Modifier - .menuAnchor(type = MenuAnchorType.PrimaryNotEditable, enabled = true) - .fillMaxWidth() + modifier = Modifier.menuAnchor(type = MenuAnchorType.PrimaryNotEditable, enabled = true).fillMaxWidth() ) - ExposedDropdownMenu( - expanded = expandedInputType, - onDismissRequest = { expandedInputType = false } - ) { + ExposedDropdownMenu(expanded = expandedInputType, onDismissRequest = { expandedInputType = false }) { allowedInputTypesForKey.forEach { type -> DropdownMenuItem( - text = { - Text( - type.name.lowercase().replaceFirstChar { it.uppercase() }) - }, - onClick = { - selectedInputType = type - expandedInputType = false - } + text = { Text(type.name.lowercase().replaceFirstChar { it.uppercase() }) }, + onClick = { selectedInputType = type; expandedInputType = false } ) } } @@ -441,17 +521,10 @@ fun MeasurementTypeDetailScreen( } OutlinedSettingRow(label = stringResource(R.string.measurement_type_label_pinned)) { - Switch( - checked = isPinned, - onCheckedChange = { isPinned = it } - ) + Switch(checked = isPinned, onCheckedChange = { isPinned = it }) } - OutlinedSettingRow(label = stringResource(R.string.measurement_type_label_on_right_y_axis)) { - Switch( - checked = isOnRightYAxis, - onCheckedChange = { isOnRightYAxis = it } - ) + Switch(checked = isOnRightYAxis, onCheckedChange = { isOnRightYAxis = it }) } } @@ -462,19 +535,48 @@ fun MeasurementTypeDetailScreen( onDismiss = { showColorPicker = false } ) } - if (showIconPicker) { IconPickerDialog( iconBackgroundColor = Color(selectedColor), - onIconSelected = { - selectedIcon = it - showIconPicker = false - }, + onIconSelected = { selectedIcon = it; showIconPicker = false }, onDismiss = { showIconPicker = false } ) } + + if (formulaInfoTitle != null && formulaInfoText != null) { + AlertDialog( + onDismissRequest = { formulaInfoTitle = null; formulaInfoText = null }, + icon = { + Icon( + imageVector = Icons.Outlined.Info, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(20.dp) + ) + }, + title = { + Text( + text = formulaInfoTitle!!, + style = MaterialTheme.typography.titleLarge + ) + }, + text = { + Text( + text = formulaInfoText!!, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + }, + confirmButton = { + TextButton(onClick = { formulaInfoTitle = null; formulaInfoText = null }) { + Text(stringResource(R.string.dialog_ok)) + } + } + ) + } } + @Composable private fun OutlinedSettingRow( label: String, @@ -507,3 +609,93 @@ private fun OutlinedSettingRow( } } } + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun FormulaPickerRow( + label: String, + currentText: String, + options: List, + optionLabel: (T) -> String, + optionSubtitle: ((T) -> String)? = null, + onInfo: ((T) -> Unit)? = null, + onSelect: (T) -> Unit +) { + var expanded by remember { mutableStateOf(false) } + + ExposedDropdownMenuBox( + expanded = expanded, + onExpandedChange = { expanded = !expanded }, + modifier = Modifier.fillMaxWidth() + ) { + OutlinedSettingRow( + label = label, + surfaceModifier = Modifier + .menuAnchor(type = MenuAnchorType.PrimaryNotEditable, enabled = true) + .clickable { expanded = true }, + controlContent = { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = currentText, + style = MaterialTheme.typography.bodyLarge, + maxLines = 2, + overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis, + modifier = Modifier + .widthIn(min = 24.dp, max = 240.dp) + .padding(end = 12.dp), + textAlign = androidx.compose.ui.text.style.TextAlign.End + ) + ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) + } + } + ) + + ExposedDropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false }, + modifier = Modifier.exposedDropdownSize(matchTextFieldWidth = true) + ) { + options.forEach { opt -> + DropdownMenuItem( + text = { + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.End + ) { + Text( + optionLabel(opt), + textAlign = androidx.compose.ui.text.style.TextAlign.End + ) + optionSubtitle?.let { sub -> + Text( + sub(opt), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 2, + overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis, + textAlign = androidx.compose.ui.text.style.TextAlign.End + ) + } + } + }, + trailingIcon = if (onInfo != null) { + { + Icon( + imageVector = Icons.Outlined.Info, + contentDescription = null, + modifier = Modifier + .size(18.dp) + .clickable { onInfo(opt) }, + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } else null, + onClick = { + expanded = false + onSelect(opt) + } + ) + } + } + } +} diff --git a/android_app/app/src/main/res/values-de/strings.xml b/android_app/app/src/main/res/values-de/strings.xml index d7e94345..81c10c0d 100644 --- a/android_app/app/src/main/res/values-de/strings.xml +++ b/android_app/app/src/main/res/values-de/strings.xml @@ -180,6 +180,52 @@ Einheitenänderung bestätigen Wenn Sie die Einheit für \'%1$s\' von %2$s auf %3$s ändern, werden alle vorhandenen Datenpunkte konvertiert. Dieser Vorgang kann einige Zeit dauern und kann nicht einfach rückgängig gemacht werden. Möchten Sie fortfahren? + Körperfett-Formel + Körperwasser-Formel + Magermasse-Formel + Formelberechnung + Diese Messgröße wird schreibgeschützt und automatisch berechnet. Sie aktualisiert sich bei Änderungen der Basiswerte. Gilt für neue oder bearbeitete Messungen; bestehende bleiben unverändert. + Aus + Deurenberg (1991) + Deurenberg (1992) + Eddy et al. (1976) + Gallagher (2000) – Nicht-asiatisch + Gallagher (2000) – Asiatisch + Behnke et al. (1963) + Delwaide & Crenier (1973) + Hume & Weyers (1971) + Lee, Song & Kim (2001) + Boer (1984) + Hume (1966) + Gewicht − Körperfett + + Keine automatische Berechnung + Diese Messgröße wird nicht automatisch berechnet. Werte können manuell eingegeben werden. + Klassische Regression (Erw.) + Weit verbreitete Bevölkerungsformel von 1991. Für die Allgemeinbevölkerung gedacht; bei sehr schlanken oder sehr muskulösen Personen ggf. ungenauer. + Angepasst für Jugend & Erw. + Variante mit Anpassung für jüngere Nutzer. Allgemeine Schätzung; sportliche oder atypische Körperbauten können abweichen. + Frühes, geschlechtsspez. Modell + Ältere Regression mit getrennten Koeffizienten für Männer und Frauen. Einfach, aber nicht für Spezialpopulationen optimiert. + Gallagher (Nicht-asiatisch) + Referenzmodell von 2000 für nicht-asiatische Populationen. Nützliche Basis; Genauigkeit variiert je nach Körpertyp. + Gallagher (Asiatisch) + Variante für asiatische Populationen. Verwenden, wenn dies besser zum Nutzerhintergrund passt. + Behnke TBW-Schätzung + Klassische Schätzung des Gesamtkörperwassers. Einfach, jedoch nicht für Athleten oder Randfälle individualisiert. + Delwaide & Crenier + TBW-Formel mit Fokus auf Körpermasse. Gute allgemeine Schätzung; Präzision variiert mit dem Körpertyp. + Hume–Weyers TBW + Weit verbreitetes TBW-Modell mit Körpergröße und Gewicht, geschlechtsspezifisch. + Lee–Song–Kim + Neuere TBW-Schätzung, kalibriert an koreanischen Kollektiven. Sinnvoll, wenn dies zum Nutzer passt. + Boer LBM (klinisch) + Fettfreie Masse, häufig in der Klinik genutzt. Geschlechtsspezifische Regression aus Größe und Gewicht. + Hume LBM + Frühes LBM-Modell. Einfach und verbreitet; für sehr sportliche Körpertypen weniger angepasst. + Gewicht − Körperfett + Berechnet LBM als Gesamtgewicht minus Fettmasse aus der gewählten Körperfettformel. Genauigkeit hängt von der BF-Schätzung ab. + Verbindung zur Waage fehlgeschlagen, bitte stellen Sie sicher, dass sie eingeschaltet ist. Bitte barfuß auf die Waage steigen diff --git a/android_app/app/src/main/res/values/strings.xml b/android_app/app/src/main/res/values/strings.xml index cd68e952..184a16d9 100644 --- a/android_app/app/src/main/res/values/strings.xml +++ b/android_app/app/src/main/res/values/strings.xml @@ -182,6 +182,53 @@ Confirm Unit Change Changing the unit for \'%1$s\' from %2$s to %3$s will convert all existing data points. This action may take some time and cannot be easily undone. Do you want to proceed? + Body Fat Formula + Body Water Formula + Lean Body Mass Formula + Formula Calculation + This metric becomes read-only and auto-calculated. It updates when base values change. Applies to new or edited measurements; existing ones stay unchanged. + + Off + Deurenberg (1991) + Deurenberg (1992) + Eddy et al. (1976) + Gallagher (2000) – Non-Asian + Gallagher (2000) – Asian + Behnke et al. (1963) + Delwaide & Crenier (1973) + Hume & Weyers (1971) + Lee, Song & Kim (2001) + Boer (1984) + Hume (1966) + Weight − Body Fat + + No automatic calculation + This metric is not auto-calculated. You can enter values manually. + Classic regression for adults + A widely used population formula from 1991. Suited for general adult populations; may be less accurate for very lean or very muscular individuals. + Adjusted for teens & adults + Variant that adapts for younger users. A general-purpose estimate; athletic or atypical physiques can deviate. + Early sex-specific model + Older regression with separate coefficients for men and women. Simple and fast but not tailored to special populations. + Gallagher (non-Asian) + Reference model from 2000 for non-Asian populations. Useful baseline; accuracy varies across body types. + Gallagher (Asian) + Companion model tuned for Asian populations. Use when this better matches the user’s background. + Behnke TBW estimate + Classic total body water estimate. A simple approach; not individualized for athletes or edge cases. + Delwaide & Crenier + TBW formula emphasizing body mass. Reasonable general estimate; precision varies with physique. + Hume–Weyers TBW + Common TBW model using height and weight, with sex-specific coefficients. + Lee–Song–Kim + Later TBW estimate calibrated on Korean cohorts. Consider if that population match applies. + Boer LBM (clinical) + Lean body mass estimate popular in clinical settings. Sex-specific regression from height and weight. + Hume LBM + Early LBM model. Simple and widely used; not tailored to very athletic builds. + Weight − Body Fat + Computes LBM as total weight minus fat mass derived from the chosen body-fat formula. Accuracy depends on the BF estimate. + Could not connect to scale, please ensure it is on. Please step barefoot on the scale