1
0
mirror of https://github.com/oliexdev/openScale.git synced 2025-09-02 21:02:48 +02:00

This commit introduces the ability to select and apply body composition formulas (Body Fat, Body Water, LBM) within the measurement type detail screen.

This commit is contained in:
oliexdev
2025-08-29 20:17:15 +02:00
parent 96143e6a25
commit 7be6b9575c
7 changed files with 878 additions and 218 deletions

View File

@@ -399,4 +399,97 @@ enum class ConnectionStatus {
DISCONNECTING,
/** A connection attempt failed or connection broke unexpectedly. */
FAILED
}
}
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)
}
}

View File

@@ -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<Set<String>>
suspend fun setReminderDays(days: Set<String>)
val selectedBodyFatFormula: Flow<BodyFatFormulaOption>
suspend fun setSelectedBodyFatFormula(option: BodyFatFormulaOption)
val selectedBodyWaterFormula: Flow<BodyWaterFormulaOption>
suspend fun setSelectedBodyWaterFormula(option: BodyWaterFormulaOption)
val selectedLbmFormula: Flow<LbmFormulaOption>
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 <T> observeSetting(keyName: String, defaultValue: T): Flow<T> {
LogManager.v(TAG, "Observing setting: key='$keyName', type='${defaultValue!!::class.simpleName}'")

View File

@@ -0,0 +1,215 @@
/*
* openScale
* Copyright (C) 2025 olie.xdev <olie.xdeveloper@googlemail.com>
*
* 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.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<MeasurementValue>
): List<MeasurementValue> {
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
}
}

View File

@@ -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<MeasurementValue>
): Result<Int> = runCatching {
val finalValues : List<MeasurementValue> = 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)

View File

@@ -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<String?>(null) }
var formulaInfoText by remember { mutableStateOf<String?>(null) }
var pendingDialog by remember { mutableStateOf<PendingDialog?>(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<MeasurementType?>(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 <T> FormulaPickerRow(
label: String,
currentText: String,
options: List<T>,
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)
}
)
}
}
}
}

View File

@@ -180,6 +180,52 @@
<string name="measurement_type_dialog_confirm_unit_change_title">Einheitenänderung bestätigen</string>
<string name="measurement_type_dialog_confirm_unit_change_message">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?</string>
<string name="formula_label_body_fat">Körperfett-Formel</string>
<string name="formula_label_body_water">Körperwasser-Formel</string>
<string name="formula_label_lbm">Magermasse-Formel</string>
<string name="formula_warning_title">Formelberechnung</string>
<string name="formula_warning_message">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.</string>
<string name="formula_off">Aus</string>
<string name="formula_bf_deurenberg_1991">Deurenberg (1991)</string>
<string name="formula_bf_deurenberg_1992">Deurenberg (1992)</string>
<string name="formula_bf_eddy_1976">Eddy et al. (1976)</string>
<string name="formula_bf_gallagher_2000_non_asian">Gallagher (2000) Nicht-asiatisch</string>
<string name="formula_bf_gallagher_2000_asian">Gallagher (2000) Asiatisch</string>
<string name="formula_bw_behnke_1963">Behnke et al. (1963)</string>
<string name="formula_bw_delwaide_crenier_1973">Delwaide &amp; Crenier (1973)</string>
<string name="formula_bw_hume_weyers_1971">Hume &amp; Weyers (1971)</string>
<string name="formula_bw_lee_song_kim_2001">Lee, Song &amp; Kim (2001)</string>
<string name="formula_lbm_boer_1984">Boer (1984)</string>
<string name="formula_lbm_hume_1966">Hume (1966)</string>
<string name="formula_lbm_weight_minus_body_fat">Gewicht Körperfett</string>
<string name="formula_desc_off_short">Keine automatische Berechnung</string>
<string name="formula_desc_off_long">Diese Messgröße wird nicht automatisch berechnet. Werte können manuell eingegeben werden.</string>
<string name="bf_deurenberg_1991_short">Klassische Regression (Erw.)</string>
<string name="bf_deurenberg_1991_long">Weit verbreitete Bevölkerungsformel von 1991. Für die Allgemeinbevölkerung gedacht; bei sehr schlanken oder sehr muskulösen Personen ggf. ungenauer.</string>
<string name="bf_deurenberg_1992_short">Angepasst für Jugend &amp; Erw.</string>
<string name="bf_deurenberg_1992_long">Variante mit Anpassung für jüngere Nutzer. Allgemeine Schätzung; sportliche oder atypische Körperbauten können abweichen.</string>
<string name="bf_eddy_1976_short">Frühes, geschlechtsspez. Modell</string>
<string name="bf_eddy_1976_long">Ältere Regression mit getrennten Koeffizienten für Männer und Frauen. Einfach, aber nicht für Spezialpopulationen optimiert.</string>
<string name="bf_gallagher_2000_non_asian_short">Gallagher (Nicht-asiatisch)</string>
<string name="bf_gallagher_2000_non_asian_long">Referenzmodell von 2000 für nicht-asiatische Populationen. Nützliche Basis; Genauigkeit variiert je nach Körpertyp.</string>
<string name="bf_gallagher_2000_asian_short">Gallagher (Asiatisch)</string>
<string name="bf_gallagher_2000_asian_long">Variante für asiatische Populationen. Verwenden, wenn dies besser zum Nutzerhintergrund passt.</string>
<string name="bw_behnke_1963_short">Behnke TBW-Schätzung</string>
<string name="bw_behnke_1963_long">Klassische Schätzung des Gesamtkörperwassers. Einfach, jedoch nicht für Athleten oder Randfälle individualisiert.</string>
<string name="bw_delwaide_crenier_1973_short">Delwaide &amp; Crenier</string>
<string name="bw_delwaide_crenier_1973_long">TBW-Formel mit Fokus auf Körpermasse. Gute allgemeine Schätzung; Präzision variiert mit dem Körpertyp.</string>
<string name="bw_hume_weyers_1971_short">HumeWeyers TBW</string>
<string name="bw_hume_weyers_1971_long">Weit verbreitetes TBW-Modell mit Körpergröße und Gewicht, geschlechtsspezifisch.</string>
<string name="bw_lee_song_kim_2001_short">LeeSongKim</string>
<string name="bw_lee_song_kim_2001_long">Neuere TBW-Schätzung, kalibriert an koreanischen Kollektiven. Sinnvoll, wenn dies zum Nutzer passt.</string>
<string name="lbm_boer_1984_short">Boer LBM (klinisch)</string>
<string name="lbm_boer_1984_long">Fettfreie Masse, häufig in der Klinik genutzt. Geschlechtsspezifische Regression aus Größe und Gewicht.</string>
<string name="lbm_hume_1966_short">Hume LBM</string>
<string name="lbm_hume_1966_long">Frühes LBM-Modell. Einfach und verbreitet; für sehr sportliche Körpertypen weniger angepasst.</string>
<string name="lbm_weight_minus_bf_short">Gewicht Körperfett</string>
<string name="lbm_weight_minus_bf_long">Berechnet LBM als Gesamtgewicht minus Fettmasse aus der gewählten Körperfettformel. Genauigkeit hängt von der BF-Schätzung ab.</string>
<!-- Bluetooth -->
<string name="info_bluetooth_connection_error_scale_offline">Verbindung zur Waage fehlgeschlagen, bitte stellen Sie sicher, dass sie eingeschaltet ist.</string>
<string name="info_step_on_scale">Bitte barfuß auf die Waage steigen</string>

View File

@@ -182,6 +182,53 @@
<string name="measurement_type_dialog_confirm_unit_change_title">Confirm Unit Change</string>
<string name="measurement_type_dialog_confirm_unit_change_message">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?</string>
<string name="formula_label_body_fat">Body Fat Formula</string>
<string name="formula_label_body_water">Body Water Formula</string>
<string name="formula_label_lbm">Lean Body Mass Formula</string>
<string name="formula_warning_title">Formula Calculation</string>
<string name="formula_warning_message">This metric becomes read-only and auto-calculated. It updates when base values change. Applies to new or edited measurements; existing ones stay unchanged.</string>
<string name="formula_off">Off</string>
<string name="formula_bf_deurenberg_1991">Deurenberg (1991)</string>
<string name="formula_bf_deurenberg_1992">Deurenberg (1992)</string>
<string name="formula_bf_eddy_1976">Eddy et al. (1976)</string>
<string name="formula_bf_gallagher_2000_non_asian">Gallagher (2000) Non-Asian</string>
<string name="formula_bf_gallagher_2000_asian">Gallagher (2000) Asian</string>
<string name="formula_bw_behnke_1963">Behnke et al. (1963)</string>
<string name="formula_bw_delwaide_crenier_1973">Delwaide &amp; Crenier (1973)</string>
<string name="formula_bw_hume_weyers_1971">Hume &amp; Weyers (1971)</string>
<string name="formula_bw_lee_song_kim_2001">Lee, Song &amp; Kim (2001)</string>
<string name="formula_lbm_boer_1984">Boer (1984)</string>
<string name="formula_lbm_hume_1966">Hume (1966)</string>
<string name="formula_lbm_weight_minus_body_fat">Weight Body Fat</string>
<string name="formula_desc_off_short">No automatic calculation</string>
<string name="formula_desc_off_long">This metric is not auto-calculated. You can enter values manually.</string>
<string name="bf_deurenberg_1991_short">Classic regression for adults</string>
<string name="bf_deurenberg_1991_long">A widely used population formula from 1991. Suited for general adult populations; may be less accurate for very lean or very muscular individuals.</string>
<string name="bf_deurenberg_1992_short">Adjusted for teens &amp; adults</string>
<string name="bf_deurenberg_1992_long">Variant that adapts for younger users. A general-purpose estimate; athletic or atypical physiques can deviate.</string>
<string name="bf_eddy_1976_short">Early sex-specific model</string>
<string name="bf_eddy_1976_long">Older regression with separate coefficients for men and women. Simple and fast but not tailored to special populations.</string>
<string name="bf_gallagher_2000_non_asian_short">Gallagher (non-Asian)</string>
<string name="bf_gallagher_2000_non_asian_long">Reference model from 2000 for non-Asian populations. Useful baseline; accuracy varies across body types.</string>
<string name="bf_gallagher_2000_asian_short">Gallagher (Asian)</string>
<string name="bf_gallagher_2000_asian_long">Companion model tuned for Asian populations. Use when this better matches the users background.</string>
<string name="bw_behnke_1963_short">Behnke TBW estimate</string>
<string name="bw_behnke_1963_long">Classic total body water estimate. A simple approach; not individualized for athletes or edge cases.</string>
<string name="bw_delwaide_crenier_1973_short">Delwaide &amp; Crenier</string>
<string name="bw_delwaide_crenier_1973_long">TBW formula emphasizing body mass. Reasonable general estimate; precision varies with physique.</string>
<string name="bw_hume_weyers_1971_short">HumeWeyers TBW</string>
<string name="bw_hume_weyers_1971_long">Common TBW model using height and weight, with sex-specific coefficients.</string>
<string name="bw_lee_song_kim_2001_short">LeeSongKim</string>
<string name="bw_lee_song_kim_2001_long">Later TBW estimate calibrated on Korean cohorts. Consider if that population match applies.</string>
<string name="lbm_boer_1984_short">Boer LBM (clinical)</string>
<string name="lbm_boer_1984_long">Lean body mass estimate popular in clinical settings. Sex-specific regression from height and weight.</string>
<string name="lbm_hume_1966_short">Hume LBM</string>
<string name="lbm_hume_1966_long">Early LBM model. Simple and widely used; not tailored to very athletic builds.</string>
<string name="lbm_weight_minus_bf_short">Weight Body Fat</string>
<string name="lbm_weight_minus_bf_long">Computes LBM as total weight minus fat mass derived from the chosen body-fat formula. Accuracy depends on the BF estimate.</string>
<!-- Bluetooth -->
<string name="info_bluetooth_connection_error_scale_offline">Could not connect to scale, please ensure it is on.</string>
<string name="info_step_on_scale">Please step barefoot on the scale</string>