From cf6d834db79642a0a749844ea0c16dfae268e753 Mon Sep 17 00:00:00 2001 From: oliexdev Date: Mon, 18 Aug 2025 16:52:10 +0200 Subject: [PATCH] Introduces a new framework for evaluating measurement values against reference ranges and displays this information in the UI. --- android_app/app/build.gradle.kts | 1 + .../com/health/openscale/core/data/Enums.kt | 15 + .../core/database/DatabaseRepository.kt | 81 ++-- .../core/eval/MeasurementEvaluator.kt | 145 ++++++ .../core/eval/MeasurementReferenceTable.kt | 213 +++++++++ .../com/health/openscale/core/utils/Utils.kt | 12 +- .../openscale/ui/screen/SharedViewModel.kt | 27 +- .../ui/screen/components/LinearGauge.kt | 214 +++++++++ .../openscale/ui/screen/graph/GraphScreen.kt | 13 +- .../ui/screen/overview/OverviewScreen.kt | 417 +++++++++++++++--- .../ui/screen/settings/UserSettingsScreen.kt | 2 +- .../app/src/main/res/values-de/strings.xml | 4 + .../app/src/main/res/values/strings.xml | 4 + android_app/gradle/libs.versions.toml | 2 + 14 files changed, 1028 insertions(+), 122 deletions(-) create mode 100644 android_app/app/src/main/java/com/health/openscale/core/eval/MeasurementEvaluator.kt create mode 100644 android_app/app/src/main/java/com/health/openscale/core/eval/MeasurementReferenceTable.kt create mode 100644 android_app/app/src/main/java/com/health/openscale/ui/screen/components/LinearGauge.kt diff --git a/android_app/app/build.gradle.kts b/android_app/app/build.gradle.kts index e9312d82..5306e327 100644 --- a/android_app/app/build.gradle.kts +++ b/android_app/app/build.gradle.kts @@ -146,6 +146,7 @@ dependencies { implementation(libs.androidx.ui.graphics) implementation(libs.androidx.ui.tooling.preview) implementation(libs.androidx.material3) + implementation(libs.androidx.constraintlayout.compose) implementation(libs.androidx.navigation.compose) implementation(libs.androidx.worker) implementation(libs.androidx.documentfile) 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 c68cec32..c5b98b17 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 @@ -65,6 +65,7 @@ import androidx.compose.material.icons.filled.SquareFoot import androidx.compose.material.icons.filled.StackedLineChart import androidx.compose.material.icons.filled.Timer import androidx.compose.material.icons.filled.WarningAmber +import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector import com.health.openscale.R import java.util.Locale @@ -364,4 +365,18 @@ enum class BackupInterval { MONTHLY -> context.getString(R.string.interval_monthly) } } +} + +enum class EvaluationState { + LOW, + NORMAL, + HIGH, + UNDEFINED; + + fun toColor(): Color = when (this) { + LOW -> Color(0xFFEF5350) // Red 400 + NORMAL -> Color(0xFF66BB6A) // Green 400 + HIGH -> Color(0xFFFFA726) // Orange 400 + UNDEFINED -> Color(0xFFBDBDBD) // Grey 400 + } } \ No newline at end of file diff --git a/android_app/app/src/main/java/com/health/openscale/core/database/DatabaseRepository.kt b/android_app/app/src/main/java/com/health/openscale/core/database/DatabaseRepository.kt index cf7612f1..15782028 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/database/DatabaseRepository.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/database/DatabaseRepository.kt @@ -374,6 +374,11 @@ class DatabaseRepository( // User's height is assumed to be stored in CM in the User object val userHeightCm = user.heightCm + val ageAtMeasurementYears = CalculationUtil.ageOn( + dateMillis = measurement.timestamp, + birthDateMillis = user.birthDate + ) + // --- PERFORM DERIVED VALUE CALCULATIONS --- // Pass the converted values (e.g., weightKg, waistCm) to the processing functions @@ -381,13 +386,23 @@ class DatabaseRepository( processLbmCalculation(weightKg, bodyFatPercentage).also { saveOrUpdateDerivedValue(it, MeasurementTypeKey.LBM) } processWhrCalculation(waistCm, hipsCm).also { saveOrUpdateDerivedValue(it, MeasurementTypeKey.WHR) } processWhtrCalculation(waistCm, userHeightCm).also { saveOrUpdateDerivedValue(it, MeasurementTypeKey.WHTR) } - processBmrCalculation(weightKg, user).also { bmr -> // user object contains heightCm and other necessary details + processBmrCalculation( + weightKg = weightKg, + heightCm = user.heightCm, + ageYears = ageAtMeasurementYears, + gender = user.gender + ).also { bmr -> saveOrUpdateDerivedValue(bmr, MeasurementTypeKey.BMR) - // TDEE calculation depends on the BMR result processTDEECalculation(bmr, user.activityLevel).also { saveOrUpdateDerivedValue(it, MeasurementTypeKey.TDEE) } } - processFatCaliperCalculation(caliper1Cm, caliper2Cm, caliper3Cm, user) - .also { saveOrUpdateDerivedValue(it, MeasurementTypeKey.CALIPER) } + + processFatCaliperCalculation( + caliper1Cm = caliper1Cm, + caliper2Cm = caliper2Cm, + caliper3Cm = caliper3Cm, + ageYears = ageAtMeasurementYears, + gender = user.gender + ).also { saveOrUpdateDerivedValue(it, MeasurementTypeKey.CALIPER) } LogManager.i(DERIVED_VALUES_TAG, "Finished recalculation of derived values for measurementId: $measurementId") } @@ -443,31 +458,25 @@ class DatabaseRepository( } } - private fun processBmrCalculation(weightKg: Float?, user: User): Float? { - LogManager.v(CALC_PROCESS_TAG, "Processing BMR for user ${user.id}: weight=$weightKg kg") - val heightCm = user.heightCm - val birthDateTimestamp = user.birthDate - val gender = user.gender + private fun processBmrCalculation( + weightKg: Float?, + heightCm: Float?, + ageYears: Int, + gender: GenderType + ): Float? { + LogManager.v(CALC_PROCESS_TAG, "Processing BMR: weight=$weightKg kg, height=$heightCm cm, age=$ageYears, gender=$gender") if (weightKg == null || weightKg <= 0f || heightCm == null || heightCm <= 0f || - birthDateTimestamp <= 0L + ageYears !in 1..120 ) { - LogManager.d(CALC_PROCESS_TAG, "BMR calculation skipped: Missing or invalid weight, height, birthdate, or gender.") + LogManager.d(CALC_PROCESS_TAG, "BMR calculation skipped: Missing/invalid weight, height or age ($ageYears).") return null } - val ageYears = CalculationUtil.dateToAge(birthDateTimestamp) - LogManager.v(CALC_PROCESS_TAG, "Calculated age for BMR: $ageYears years") - - return if (ageYears in 1..120) { - when (gender) { - GenderType.MALE -> (10.0f * weightKg) + (6.25f * heightCm) - (5.0f * ageYears) + 5.0f - GenderType.FEMALE -> (10.0f * weightKg) + (6.25f * heightCm) - (5.0f * ageYears) - 161.0f - } - } else { - LogManager.w(CALC_PROCESS_TAG, "Invalid age for BMR calculation: $ageYears years. User ID: ${user.id}") - null + return when (gender) { + GenderType.MALE -> (10.0f * weightKg) + (6.25f * heightCm) - (5.0f * ageYears) + 5.0f + GenderType.FEMALE -> (10.0f * weightKg) + (6.25f * heightCm) - (5.0f * ageYears) - 161.0f } } @@ -488,13 +497,18 @@ class DatabaseRepository( return bmr * activityFactor } + private fun processFatCaliperCalculation( caliper1Cm: Float?, caliper2Cm: Float?, caliper3Cm: Float?, - user: User + ageYears: Int, + gender: GenderType ): Float? { - LogManager.v(CALC_PROCESS_TAG, "Processing Fat Caliper: c1=$caliper1Cm cm, c2=$caliper2Cm cm, c3=$caliper3Cm cm for user ${user.id}") + LogManager.v( + CALC_PROCESS_TAG, + "Processing Fat Caliper: c1=$caliper1Cm cm, c2=$caliper2Cm cm, c3=$caliper3Cm cm, age=$ageYears, gender=$gender" + ) if (caliper1Cm == null || caliper1Cm <= 0f || caliper2Cm == null || caliper2Cm <= 0f || @@ -504,18 +518,16 @@ class DatabaseRepository( return null } - val gender = user.gender - val ageYears = CalculationUtil.dateToAge(user.birthDate) - if (ageYears <= 0) { - LogManager.w(CALC_PROCESS_TAG, "Fat Caliper calculation skipped: Invalid gender ($gender) or age ($ageYears years). User ID: ${user.id}") + LogManager.w(CALC_PROCESS_TAG, "Fat Caliper calculation skipped: Invalid age ($ageYears).") return null } - LogManager.v(CALC_PROCESS_TAG, "Calculated age for Fat Caliper: $ageYears years") + // Sum of skinfolds in millimeters val sumSkinfoldsMm = (caliper1Cm + caliper2Cm + caliper3Cm) * 10.0f LogManager.v(CALC_PROCESS_TAG, "Sum of skinfolds (S): $sumSkinfoldsMm mm") + // Choose constants based on gender val k0: Float val k1: Float val k2: Float @@ -536,21 +548,20 @@ class DatabaseRepository( } } - val bodyDensity = k0 - (k1 * sumSkinfoldsMm) + (k2 * sumSkinfoldsMm * sumSkinfoldsMm) - (ka * ageYears) + val bodyDensity = + k0 - (k1 * sumSkinfoldsMm) + (k2 * sumSkinfoldsMm * sumSkinfoldsMm) - (ka * ageYears) LogManager.v(CALC_PROCESS_TAG, "Calculated Body Density (BD): $bodyDensity") if (bodyDensity <= 0f) { - LogManager.w(CALC_PROCESS_TAG, "Invalid Body Density calculated: $bodyDensity. Caliper values might be outside the formula's valid range. User ID: ${user.id}") + LogManager.w(CALC_PROCESS_TAG, "Invalid Body Density calculated: $bodyDensity.") return null } val fatPercentage = (4.95f / bodyDensity - 4.5f) * 100.0f LogManager.v(CALC_PROCESS_TAG, "Calculated Fat Percentage from BD: $fatPercentage %") - return if (fatPercentage in 1.0f..70.0f) { - fatPercentage - } else { - LogManager.w(CALC_PROCESS_TAG, "Calculated Fat Percentage ($fatPercentage%) is outside the expected physiological range (1-70%). User ID: ${user.id}") + return fatPercentage.takeIf { it in 1.0f..70.0f } ?: run { + LogManager.w(CALC_PROCESS_TAG, "Calculated Fat Percentage ($fatPercentage%) is outside the expected physiological range (1–70%).") fatPercentage } } diff --git a/android_app/app/src/main/java/com/health/openscale/core/eval/MeasurementEvaluator.kt b/android_app/app/src/main/java/com/health/openscale/core/eval/MeasurementEvaluator.kt new file mode 100644 index 00000000..504698d3 --- /dev/null +++ b/android_app/app/src/main/java/com/health/openscale/core/eval/MeasurementEvaluator.kt @@ -0,0 +1,145 @@ +/* + * 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.eval + +import com.health.openscale.core.data.EvaluationState +import com.health.openscale.core.data.GenderType +import com.health.openscale.core.data.MeasurementTypeKey +import com.health.openscale.core.utils.CalculationUtil +import com.health.openscale.ui.screen.UserEvaluationContext + +data class MeasurementEvaluationResult( + val value: Float, + val lowLimit: Float, + val highLimit: Float, + val state: EvaluationState +) + +/** + * High-level evaluation API for measurements. + * Delegates to MeasurementReferenceTable strategies. + */ +object MeasurementEvaluator { + + /** + * Central entry point for evaluation in UI. + * + * @param typeKey The measurement type key. + * @param value The numeric value to evaluate. + * @param userEvaluationContext User context (gender, height, birthDate). + * @param measuredAtMillis Timestamp of the measurement (for age-on calculation). + * + * @return MeasurementEvaluationResult or null if type is not supported. + */ + fun evaluate( + typeKey: MeasurementTypeKey, + value: Float, + userEvaluationContext: UserEvaluationContext, + measuredAtMillis: Long + ): MeasurementEvaluationResult? { + if (!value.isFinite()) return null + + val ageYears = CalculationUtil.ageOn( + measuredAtMillis, + userEvaluationContext.birthDateMillis + ) + + return when (typeKey) { + MeasurementTypeKey.BODY_FAT -> evalBodyFat(value, ageYears, userEvaluationContext.gender) + MeasurementTypeKey.WATER -> evalWater(value, ageYears, userEvaluationContext.gender) + MeasurementTypeKey.MUSCLE -> evalMuscle(value, ageYears, userEvaluationContext.gender) + MeasurementTypeKey.LBM -> evalLBM(value, ageYears, userEvaluationContext.gender) + + MeasurementTypeKey.BMI -> evalBmi(value, ageYears, userEvaluationContext.gender) + MeasurementTypeKey.WHTR -> evalWHtR(value, ageYears) + MeasurementTypeKey.WHR -> evalWHR(value, ageYears, userEvaluationContext.gender) + MeasurementTypeKey.VISCERAL_FAT -> evalVisceralFat(value, ageYears) + + MeasurementTypeKey.WAIST -> evalWaistCm(value, ageYears, userEvaluationContext.gender) + MeasurementTypeKey.WEIGHT -> evalWeightAgainstTargetRange( + weightKg = value, + age = ageYears, + heightCm = userEvaluationContext.heightCm.toInt(), + gender = userEvaluationContext.gender + ) + + else -> null + } + } + // --- Body composition --- + + fun evalBodyFat(value: Float, age: Int, gender: GenderType): MeasurementEvaluationResult = + when (gender) { + GenderType.MALE -> MeasurementReferenceTable.fatMale.evaluate(value, age) + GenderType.FEMALE -> MeasurementReferenceTable.fatFemale.evaluate(value, age) + } + + fun evalWater(value: Float, age: Int, gender: GenderType): MeasurementEvaluationResult = + when (gender) { + GenderType.MALE -> MeasurementReferenceTable.waterMale.evaluate(value, age) + GenderType.FEMALE -> MeasurementReferenceTable.waterFemale.evaluate(value, age) + } + + fun evalMuscle(value: Float, age: Int, gender: GenderType): MeasurementEvaluationResult = + when (gender) { + GenderType.MALE -> MeasurementReferenceTable.muscleMale.evaluate(value, age) + GenderType.FEMALE -> MeasurementReferenceTable.muscleFemale.evaluate(value, age) + } + + fun evalLBM(value: Float, age: Int, gender: GenderType): MeasurementEvaluationResult = + when (gender) { + GenderType.MALE -> MeasurementReferenceTable.lbmMale.evaluate(value, age) + GenderType.FEMALE -> MeasurementReferenceTable.lbmFemale.evaluate(value, age) + } + + // --- Indices --- + + fun evalBmi(value: Float, age: Int, gender: GenderType): MeasurementEvaluationResult = + when (gender) { + GenderType.MALE -> MeasurementReferenceTable.bmiMale.evaluate(value, age) + GenderType.FEMALE -> MeasurementReferenceTable.bmiFemale.evaluate(value, age) + } + + fun evalWHtR(value: Float, age: Int): MeasurementEvaluationResult = + MeasurementReferenceTable.whtr.evaluate(value, age) + + fun evalWHR(value: Float, age: Int, gender: GenderType): MeasurementEvaluationResult = + when (gender) { + GenderType.MALE -> MeasurementReferenceTable.whrMale.evaluate(value, age) + GenderType.FEMALE -> MeasurementReferenceTable.whrFemale.evaluate(value, age) + } + + fun evalVisceralFat(value: Float, age: Int): MeasurementEvaluationResult = + MeasurementReferenceTable.visceralFat.evaluate(value, age) + + // --- Circumference / targets --- + + fun evalWaistCm(value: Float, age: Int, gender: GenderType): MeasurementEvaluationResult = + MeasurementReferenceTable.waistStrategyCm(gender).evaluate(value, age) + + /** Evaluates current weight against BMI-derived target range for given height/gender. */ + fun evalWeightAgainstTargetRange( + weightKg: Float, + age: Int, + heightCm: Int, + gender: GenderType + ): MeasurementEvaluationResult = + MeasurementReferenceTable + .targetWeightStrategy(heightCm, gender) + .evaluate(weightKg, age) +} diff --git a/android_app/app/src/main/java/com/health/openscale/core/eval/MeasurementReferenceTable.kt b/android_app/app/src/main/java/com/health/openscale/core/eval/MeasurementReferenceTable.kt new file mode 100644 index 00000000..ce3522dd --- /dev/null +++ b/android_app/app/src/main/java/com/health/openscale/core/eval/MeasurementReferenceTable.kt @@ -0,0 +1,213 @@ +/* + * 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.eval + +import com.health.openscale.core.data.EvaluationState +import com.health.openscale.core.data.GenderType + +/** + * Reference tables and strategies used to evaluate measurements. + * Uses existing MeasurementEvaluationResult (in this package) and EvaluationState (in core.data). + */ +object MeasurementReferenceTable { + + // ----- Public strategy types ----- + + /** Contract for evaluating a value against reference bounds. */ + interface EvaluationStrategy { + fun evaluate(value: Float, age: Int): MeasurementEvaluationResult + } + + /** Age-specific reference range. */ + data class AgeBand( + val ageMin: Int, + val ageMax: Int, + val low: Float, + val high: Float + ) + + /** Strategy selecting bounds by age band. */ + class AgeBandStrategy(private val bands: List) : EvaluationStrategy { + override fun evaluate(value: Float, age: Int): MeasurementEvaluationResult { + val band = bands.firstOrNull { age in it.ageMin..it.ageMax } + ?: return MeasurementEvaluationResult(value, -1f, -1f, EvaluationState.UNDEFINED) + + val state = when { + value < band.low -> EvaluationState.LOW + value > band.high -> EvaluationState.HIGH + else -> EvaluationState.NORMAL + } + return MeasurementEvaluationResult(value, band.low, band.high, state) + } + } + + /** Strategy computing bounds via formulas. */ + class FormulaStrategy( + private val low: () -> Float, + private val high: () -> Float + ) : EvaluationStrategy { + override fun evaluate(value: Float, age: Int): MeasurementEvaluationResult { + val lo = low() + val hi = high() + val state = when { + value < lo -> EvaluationState.LOW + value > hi -> EvaluationState.HIGH + else -> EvaluationState.NORMAL + } + return MeasurementEvaluationResult(value, lo, hi, state) + } + } + + // ----- Reference tables (static) ----- + + // Body Fat % + val fatMale = AgeBandStrategy( + listOf( + AgeBand(10, 14, 11f, 16f), + AgeBand(15, 19, 12f, 17f), + AgeBand(20, 29, 13f, 18f), + AgeBand(30, 39, 14f, 19f), + AgeBand(40, 49, 15f, 20f), + AgeBand(50, 59, 16f, 21f), + AgeBand(60, 69, 17f, 22f), + AgeBand(70, 1000, 18f, 23f), + ) + ) + val fatFemale = AgeBandStrategy( + listOf( + AgeBand(10, 14, 16f, 21f), + AgeBand(15, 19, 17f, 22f), + AgeBand(20, 29, 18f, 23f), + AgeBand(30, 39, 19f, 24f), + AgeBand(40, 49, 20f, 25f), + AgeBand(50, 59, 21f, 26f), + AgeBand(60, 69, 22f, 27f), + AgeBand(70, 1000, 23f, 28f), + ) + ) + + // Body Water % + val waterMale = AgeBandStrategy(listOf(AgeBand(10, 1000, 50f, 65f))) + val waterFemale = AgeBandStrategy(listOf(AgeBand(10, 1000, 45f, 60f))) + + // Muscle Mass % + val muscleMale = AgeBandStrategy( + listOf( + AgeBand(18, 29, 37.9f, 46.7f), + AgeBand(30, 39, 34.1f, 44.1f), + AgeBand(40, 49, 33.1f, 41.1f), + AgeBand(50, 59, 31.7f, 38.5f), + AgeBand(60, 69, 29.9f, 37.7f), + AgeBand(70, 1000, 28.7f, 43.3f), + ) + ) + val muscleFemale = AgeBandStrategy( + listOf( + AgeBand(18, 29, 28.4f, 39.8f), + AgeBand(30, 39, 25.0f, 36.2f), + AgeBand(40, 49, 24.2f, 34.2f), + AgeBand(50, 59, 24.7f, 33.5f), + AgeBand(60, 69, 22.7f, 31.9f), + AgeBand(70, 1000, 25.5f, 34.9f), + ) + ) + + // BMI + val bmiMale = AgeBandStrategy( + listOf( + AgeBand(16, 24, 20f, 25f), + AgeBand(25, 34, 21f, 26f), + AgeBand(35, 44, 22f, 27f), + AgeBand(45, 54, 23f, 28f), + AgeBand(55, 64, 24f, 29f), + AgeBand(65, 90, 25f, 30f), + ) + ) + val bmiFemale = AgeBandStrategy( + listOf( + AgeBand(16, 24, 19f, 24f), + AgeBand(25, 34, 20f, 25f), + AgeBand(35, 44, 21f, 26f), + AgeBand(45, 54, 22f, 27f), + AgeBand(55, 64, 23f, 28f), + AgeBand(65, 90, 24f, 29f), + ) + ) + + // WHtR + val whtr = AgeBandStrategy( + listOf( + AgeBand(15, 40, 0.4f, 0.5f), + AgeBand(41, 42, 0.4f, 0.51f), + AgeBand(43, 44, 0.4f, 0.53f), + AgeBand(45, 46, 0.4f, 0.55f), + AgeBand(47, 48, 0.4f, 0.57f), + AgeBand(49, 50, 0.4f, 0.59f), + AgeBand(51, 90, 0.4f, 0.6f), + ) + ) + + // WHR + val whrMale = AgeBandStrategy(listOf(AgeBand(18, 90, 0.8f, 0.9f))) + val whrFemale = AgeBandStrategy(listOf(AgeBand(18, 90, 0.7f, 0.8f))) + + // Visceral Fat (index) + val visceralFat = AgeBandStrategy(listOf(AgeBand(18, 90, -1f, 12f))) + + // Lean Body Mass (kg) – Italian reference P25–P75 (DOI: 10.26355/eurrev_201811_16415) + val lbmMale = AgeBandStrategy( + listOf( + AgeBand(18, 24, 52.90f, 62.70f), + AgeBand(25, 34, 53.10f, 64.80f), + AgeBand(35, 44, 53.83f, 65.60f), + AgeBand(45, 54, 53.60f, 65.20f), + AgeBand(55, 64, 51.63f, 61.10f), + AgeBand(65, 74, 48.48f, 58.20f), + AgeBand(75, 88, 43.35f, 60.23f), + ) + ) + val lbmFemale = AgeBandStrategy( + listOf( + AgeBand(18, 24, 34.30f, 41.90f), + AgeBand(25, 34, 35.20f, 43.70f), + AgeBand(35, 44, 35.60f, 47.10f), + AgeBand(45, 54, 36.10f, 44.90f), + AgeBand(55, 64, 35.15f, 43.95f), + AgeBand(65, 74, 34.10f, 42.05f), + AgeBand(75, 88, 33.80f, 40.40f), + ) + ) + + // ----- Dynamic strategies ----- + + /** Waist circumference (cm) thresholds by gender. */ + fun waistStrategyCm(gender: GenderType): EvaluationStrategy = when (gender) { + GenderType.MALE -> AgeBandStrategy(listOf(AgeBand(18, 90, -1f, 94f))) + GenderType.FEMALE -> AgeBandStrategy(listOf(AgeBand(18, 90, -1f, 80f))) + } + + /** Target weight bounds computed from height (cm) and BMI ranges by gender. */ + fun targetWeightStrategy(heightCm: Int, gender: GenderType): EvaluationStrategy { + val h2 = (heightCm / 100f) * (heightCm / 100f) + val (bmiLo, bmiHi) = if (gender == GenderType.MALE) 20f to 25f else 19f to 24f + return FormulaStrategy( + low = { h2 * bmiLo }, + high = { h2 * bmiHi } + ) + } +} diff --git a/android_app/app/src/main/java/com/health/openscale/core/utils/Utils.kt b/android_app/app/src/main/java/com/health/openscale/core/utils/Utils.kt index ec82f0f2..1304f287 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/utils/Utils.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/utils/Utils.kt @@ -30,14 +30,10 @@ import java.time.ZoneId import java.util.Locale object CalculationUtil { - fun dateToAge(birthDateMillis: Long): Int { - val birthDate = Instant.ofEpochMilli(birthDateMillis) - .atZone(ZoneId.systemDefault()) - .toLocalDate() - - val today = LocalDate.now() - - return Period.between(birthDate, today).years + fun ageOn(dateMillis: Long, birthDateMillis: Long): Int { + val birth = Instant.ofEpochMilli(birthDateMillis).atZone(ZoneId.systemDefault()).toLocalDate() + val onDate = Instant.ofEpochMilli(dateMillis).atZone(ZoneId.systemDefault()).toLocalDate() + return Period.between(birth, onDate).years.coerceAtLeast(0) } /** diff --git a/android_app/app/src/main/java/com/health/openscale/ui/screen/SharedViewModel.kt b/android_app/app/src/main/java/com/health/openscale/ui/screen/SharedViewModel.kt index 583dce4f..f77b4d95 100644 --- a/android_app/app/src/main/java/com/health/openscale/ui/screen/SharedViewModel.kt +++ b/android_app/app/src/main/java/com/health/openscale/ui/screen/SharedViewModel.kt @@ -20,24 +20,16 @@ package com.health.openscale.ui.screen import android.app.Application import android.content.ComponentName import android.content.Intent -import android.util.Log import androidx.annotation.StringRes -import androidx.compose.animation.core.copy -import androidx.compose.foundation.gestures.forEach -import androidx.compose.foundation.layout.size import androidx.compose.material3.SnackbarDuration import androidx.compose.runtime.Composable -import androidx.compose.ui.geometry.isEmpty import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.input.key.type import androidx.core.content.ContextCompat -import androidx.core.graphics.values import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider -import androidx.lifecycle.application import androidx.lifecycle.viewModelScope import com.health.openscale.R -import com.health.openscale.core.bluetooth.data.ScaleMeasurement +import com.health.openscale.core.data.GenderType import com.health.openscale.core.data.InputFieldType import com.health.openscale.core.data.Measurement import com.health.openscale.core.data.MeasurementType @@ -131,6 +123,12 @@ data class EnrichedMeasurement( val valuesWithTrend: List ) +data class UserEvaluationContext( + val gender: GenderType, + val heightCm: Float, + val birthDateMillis: Long +) + /** * Shared ViewModel for managing UI state and business logic accessible across multiple screens. * It handles user selection, measurement data (CRUD operations, display, enrichment), @@ -304,6 +302,17 @@ class SharedViewModel( LogManager.d(TAG, "Current measurement ID set to: $measurementId (UI/Navigation Action)") } + val userEvaluationContext: StateFlow = + selectedUser.map { u -> + u?.let { + UserEvaluationContext( + gender = if (it.gender.isMale()) GenderType.MALE else GenderType.FEMALE, + heightCm = it.heightCm, + birthDateMillis = it.birthDate + ) + } + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000L), null) + // --- Measurement CRUD Operations --- fun saveMeasurement(measurementToSave: Measurement, valuesToSave: List) { diff --git a/android_app/app/src/main/java/com/health/openscale/ui/screen/components/LinearGauge.kt b/android_app/app/src/main/java/com/health/openscale/ui/screen/components/LinearGauge.kt new file mode 100644 index 00000000..05883260 --- /dev/null +++ b/android_app/app/src/main/java/com/health/openscale/ui/screen/components/LinearGauge.kt @@ -0,0 +1,214 @@ +/* + * 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.ui.components + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.layout.* +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.constraintlayout.compose.ConstraintLayout +import androidx.constraintlayout.compose.ConstraintLayoutBaseScope +import androidx.constraintlayout.compose.Dimension +import com.health.openscale.core.data.EvaluationState +import kotlin.math.max +import kotlin.math.ceil + +@Composable +fun LinearGauge( + value: Float, + lowLimit: Float?, + highLimit: Float, + modifier: Modifier = Modifier + .fillMaxWidth() + .height(110.dp), // a bit taller: top labels + bar + bottom label + labelProvider: (Float) -> String = { "%.1f".format(it) } +) { + val barHeight = 10f + val EPS = 1e-3f + + val lowColor = EvaluationState.LOW.toColor() + val normalColor = EvaluationState.NORMAL.toColor() + val highColor = EvaluationState.HIGH.toColor() + val indicatorColor = MaterialTheme.colorScheme.onSurface + val guideColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.25f) + + val hasFirst = lowLimit != null + val lo = lowLimit + var hi = highLimit + if (hasFirst && lo!! > hi) hi = lo + + var span = if (hasFirst) (hi - lo!!) / 2f else 0.3f * hi + val margin = 0.05f * span + span = when { + hasFirst && value - margin < lo!! - span -> lo - value + margin + !hasFirst && value - margin < hi - span -> hi - value + margin + value + margin > hi + span -> value - hi + margin + else -> span + } + span = when { + span <= 1f -> ceil(span * 10.0) / 10.0 + span <= 10f -> ceil(span.toDouble()) + else -> 5.0 * ceil(span / 5.0) + }.toFloat().let { if (it <= EPS) 1f else it } + + val minV = if (lo == null && value >= 0f && hi >= 0f) 0f + else ((lo ?: hi) - span).coerceAtLeast(0f) + val maxV = (hi + span).let { if (it <= minV + EPS) minV + 1f else it } + val denom = (maxV - minV).let { if (it <= EPS) 1f else it } + + fun frac(v: Float) = ((v - minV) / denom).coerceIn(0f, 1f) + fun map(v: Float, w: Float) = frac(v) * w + + val fMin = 0f + val fLow = if (hasFirst) frac(lo!!) else null + val fHigh = frac(hi) + val fMax = 1f + val fVal = frac(value) // current value position (for bottom label) + + Column(modifier = modifier) { + + // ----- Top labels ----- + ConstraintLayout( + Modifier + .fillMaxWidth() + .height(14.dp) + .padding(horizontal = 4.dp) + ) { + val gMin = createGuidelineFromAbsoluteLeft(fraction = fMin) + val gLow = fLow?.let { createGuidelineFromAbsoluteLeft(fraction = it) } + val gHigh = createGuidelineFromAbsoluteLeft(fraction = fHigh) + val gMax = createGuidelineFromAbsoluteLeft(fraction = fMax) + + val style = MaterialTheme.typography.labelSmall + + @Composable + fun Label(text: String, guide: ConstraintLayoutBaseScope.VerticalAnchor) { + val ref = createRef() + Text( + text = text, + style = style, + maxLines = 1, + modifier = Modifier.constrainAs(ref) { + width = Dimension.wrapContent + start.linkTo(guide) + end.linkTo(guide) + bottom.linkTo(parent.bottom) // closer to bar + }, + textAlign = TextAlign.Center + ) + } + + Label(labelProvider(minV), gMin) + if (hasFirst) Label(labelProvider(lo!!), gLow!!) + Label(labelProvider(hi), gHigh) + Label(labelProvider(maxV), gMax) + } + + // ----- Bar + guides + indicator ----- + Box(Modifier.fillMaxWidth().height(42.dp)) { + val density = LocalDensity.current + Canvas(Modifier.fillMaxSize()) { + val w = size.width + val yTop = (size.height - barHeight) / 2f + + val xLow = if (hasFirst) map(lo, w) else 0f + val xHigh = map(hi, w) + val xVal = map(value, w) + + if (hasFirst) { + drawRect( + color = lowColor, + topLeft = Offset(0f, yTop), + size = Size(max(0f, xLow), barHeight) + ) + } + drawRect( + color = normalColor, + topLeft = Offset(xLow, yTop), + size = Size(max(0f, xHigh - xLow), barHeight) + ) + drawRect( + color = highColor, + topLeft = Offset(xHigh, yTop), + size = Size(max(0f, w - xHigh), barHeight) + ) + + val extraPx = with(density) { 10.dp.toPx() } + val tickStroke = with(density) { 1.dp.toPx() } + + fun drawGuide(x: Float) { + drawLine( + color = guideColor, + start = Offset(x, yTop - extraPx), + end = Offset(x, yTop + barHeight + extraPx), + strokeWidth = tickStroke + ) + } + + drawGuide(0f) + if (hasFirst) drawGuide(xLow) + drawGuide(xHigh) + drawGuide(w) + + // Bigger indicator triangle + val triH = 26f + val triW = 24f + drawPath( + path = androidx.compose.ui.graphics.Path().apply { + moveTo(xVal, yTop + barHeight) + lineTo(xVal - triW / 2f, yTop + barHeight + triH) + lineTo(xVal + triW / 2f, yTop + barHeight + triH) + close() + }, + color = indicatorColor + ) + } + } + + // ----- Current value label centered under marker ----- + ConstraintLayout( + Modifier + .fillMaxWidth() + .height(20.dp) + .padding(horizontal = 4.dp) + ) { + val gVal = createGuidelineFromAbsoluteLeft(fraction = fVal) + val style = MaterialTheme.typography.labelMedium + val ref = createRef() + + Text( + text = labelProvider(value), + style = style, + modifier = Modifier.constrainAs(ref) { + width = Dimension.wrapContent + start.linkTo(gVal) + end.linkTo(gVal) // horizontally center to marker + top.linkTo(parent.top) + }, + textAlign = TextAlign.Center + ) + } + } +} diff --git a/android_app/app/src/main/java/com/health/openscale/ui/screen/graph/GraphScreen.kt b/android_app/app/src/main/java/com/health/openscale/ui/screen/graph/GraphScreen.kt index 8f75d035..d51d2138 100644 --- a/android_app/app/src/main/java/com/health/openscale/ui/screen/graph/GraphScreen.kt +++ b/android_app/app/src/main/java/com/health/openscale/ui/screen/graph/GraphScreen.kt @@ -17,14 +17,12 @@ */ package com.health.openscale.ui.screen.graph -import android.content.Context import androidx.compose.foundation.layout.* import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Edit import androidx.compose.material3.* import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.SheetValue import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.* @@ -38,7 +36,6 @@ import androidx.navigation.NavController import com.health.openscale.R import com.health.openscale.core.data.Trend import com.health.openscale.core.database.UserPreferenceKeys -import com.health.openscale.core.model.MeasurementWithValues import com.health.openscale.ui.navigation.Routes import com.health.openscale.ui.screen.SharedViewModel import com.health.openscale.ui.screen.ValueWithDifference @@ -46,9 +43,6 @@ import com.health.openscale.ui.screen.components.LineChart import com.health.openscale.ui.screen.components.provideFilterTopBarAction import com.health.openscale.ui.screen.overview.MeasurementValueRow import java.text.DateFormat -import java.text.SimpleDateFormat -import java.time.Instant -import java.time.ZoneId import java.util.Date import java.util.Locale @@ -62,6 +56,7 @@ fun GraphScreen( val isLoading by sharedViewModel.isBaseDataLoading.collectAsState() val allMeasurementsWithValues by sharedViewModel.allMeasurementsForSelectedUser.collectAsState() val selectedUserId by sharedViewModel.selectedUserId.collectAsState() + val userEvalContext by sharedViewModel.userEvaluationContext.collectAsState() val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = false) var sheetMeasurementId by rememberSaveable { mutableStateOf(null) } @@ -180,11 +175,13 @@ fun GraphScreen( visibleValues.forEach { v -> MeasurementValueRow( - ValueWithDifference( + valueWithTrend = ValueWithDifference( currentValue = v, difference = null, trend = Trend.NOT_APPLICABLE - ) + ), + userEvaluationContext = userEvalContext, + measuredAtMillis = mwv.measurement.timestamp ) } } diff --git a/android_app/app/src/main/java/com/health/openscale/ui/screen/overview/OverviewScreen.kt b/android_app/app/src/main/java/com/health/openscale/ui/screen/overview/OverviewScreen.kt index 089361a5..b015de48 100644 --- a/android_app/app/src/main/java/com/health/openscale/ui/screen/overview/OverviewScreen.kt +++ b/android_app/app/src/main/java/com/health/openscale/ui/screen/overview/OverviewScreen.kt @@ -17,17 +17,16 @@ */ package com.health.openscale.ui.screen.overview -import android.R.attr.targetId import android.content.Context import android.widget.Toast import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.animateColorAsState -import androidx.compose.animation.core.tween import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.background +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.Spacer import androidx.compose.foundation.layout.fillMaxSize @@ -39,7 +38,6 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.BluetoothSearching import androidx.compose.material.icons.filled.Add @@ -56,7 +54,6 @@ import androidx.compose.material.icons.filled.ExpandLess import androidx.compose.material.icons.filled.ExpandMore import androidx.compose.material.icons.filled.PersonAdd import androidx.compose.material.icons.filled.PersonSearch -import androidx.compose.material.icons.filled.QuestionMark import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Card @@ -75,6 +72,7 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope @@ -85,19 +83,24 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.navigation.NavController import com.health.openscale.R +import com.health.openscale.core.data.EvaluationState import com.health.openscale.core.data.InputFieldType +import com.health.openscale.core.data.MeasurementTypeKey import com.health.openscale.core.data.Trend import com.health.openscale.core.model.MeasurementWithValues import com.health.openscale.core.database.UserPreferenceKeys +import com.health.openscale.core.eval.MeasurementEvaluator +import com.health.openscale.ui.components.LinearGauge import com.health.openscale.ui.components.RoundMeasurementIcon import com.health.openscale.ui.navigation.Routes import com.health.openscale.ui.screen.SharedViewModel +import com.health.openscale.ui.screen.UserEvaluationContext import com.health.openscale.ui.screen.ValueWithDifference import com.health.openscale.ui.screen.bluetooth.BluetoothViewModel import com.health.openscale.ui.screen.bluetooth.ConnectionStatus @@ -105,9 +108,8 @@ import com.health.openscale.ui.screen.components.LineChart import com.health.openscale.ui.screen.components.provideFilterTopBarAction import kotlinx.coroutines.delay import kotlinx.coroutines.launch +import java.text.DateFormat import java.text.SimpleDateFormat -import java.time.Instant -import java.time.ZoneId import java.util.Date import java.util.Locale @@ -316,6 +318,9 @@ fun OverviewScreen( (type.inputType == InputFieldType.FLOAT || type.inputType == InputFieldType.INT) // Ensure it's a plottable type } } + + val userEvalContext by sharedViewModel.userEvaluationContext.collectAsState() + // --- End of reverted chart selection logic --- val savedDeviceAddress by bluetoothViewModel.savedScaleAddress.collectAsState() @@ -463,7 +468,8 @@ fun OverviewScreen( MeasurementCard( measurementWithValues = enrichedItem.measurementWithValues, processedValuesForDisplay = enrichedItem.valuesWithTrend, - onEdit = { + userEvaluationContext = userEvalContext, + onEdit = { navController.navigate( Routes.measurementDetail( enrichedItem.measurementWithValues.measurement.id, @@ -628,6 +634,7 @@ fun NoMeasurementsCard(navController: NavController, selectedUserId: Int?) { fun MeasurementCard( measurementWithValues: MeasurementWithValues, processedValuesForDisplay: List, + userEvaluationContext: UserEvaluationContext?, onEdit: () -> Unit, onDelete: () -> Unit, isHighlighted: Boolean = false @@ -635,6 +642,9 @@ fun MeasurementCard( val highlightColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.12f) val highlightBorder = BorderStroke(1.dp, MaterialTheme.colorScheme.primary) + val measuredAtMillis = measurementWithValues.measurement.timestamp + val expandedTypeIds = remember { mutableStateMapOf() } + val dateFormatted = remember(measurementWithValues.measurement.timestamp) { SimpleDateFormat("E, dd.MM.yyyy HH:mm", Locale.getDefault()) .format(Date(measurementWithValues.measurement.timestamp)) @@ -653,8 +663,6 @@ fun MeasurementCard( val allActiveProcessedValues = remember(processedValuesForDisplay) { processedValuesForDisplay.filter { it.currentValue.type.isEnabled } } - - Card( modifier = Modifier.fillMaxWidth(), border = if (isHighlighted) highlightBorder else null, @@ -679,19 +687,26 @@ fun MeasurementCard( modifier = Modifier.weight(1f) // Date takes available space ) val iconButtonSize = 36.dp // Standard size for action icons - val actionIconColor = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.8f) + val actionIconColor = + MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.8f) IconButton(onClick = onEdit, modifier = Modifier.size(iconButtonSize)) { Icon( Icons.Default.Edit, - contentDescription = stringResource(R.string.action_edit_measurement_desc, dateFormatted), + contentDescription = stringResource( + R.string.action_edit_measurement_desc, + dateFormatted + ), tint = actionIconColor ) } IconButton(onClick = onDelete, modifier = Modifier.size(iconButtonSize)) { Icon( Icons.Default.Delete, - contentDescription = stringResource(R.string.action_delete_measurement_desc, dateFormatted), + contentDescription = stringResource( + R.string.action_delete_measurement_desc, + dateFormatted + ), tint = actionIconColor ) } @@ -699,7 +714,10 @@ fun MeasurementCard( // Conditional expand/collapse icon button for non-pinned values, // only shown if there are non-pinned values and no pinned values (to avoid duplicate expand button logic) if (nonPinnedValues.isNotEmpty() && pinnedValues.isEmpty()) { - IconButton(onClick = { isExpanded = !isExpanded }, modifier = Modifier.size(iconButtonSize)) { + IconButton( + onClick = { isExpanded = !isExpanded }, + modifier = Modifier.size(iconButtonSize) + ) { Icon( imageVector = if (isExpanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore, contentDescription = stringResource(if (isExpanded) R.string.action_show_less_desc else R.string.action_show_more_desc) @@ -717,41 +735,51 @@ fun MeasurementCard( ) ) { if (pinnedValues.isNotEmpty()) { - Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + Column(verticalArrangement = Arrangement.spacedBy(0.dp)) { pinnedValues.forEach { valueWithTrend -> - MeasurementValueRow(valueWithTrend) + MeasurementRowExpandable( + valueWithTrend = valueWithTrend, + userEvaluationContext = userEvaluationContext, + measuredAtMillis = measuredAtMillis, + expandedTypeIds = expandedTypeIds + ) } } } } + // Animated section for non-pinned measurement values (collapsible) if (nonPinnedValues.isNotEmpty()) { AnimatedVisibility(visible = isExpanded || pinnedValues.isEmpty()) { // Also visible if no pinned values and not expanded (default state) Column( - modifier = Modifier.padding( - start = 16.dp, end = 16.dp, - top = if (pinnedValues.isNotEmpty()) 12.dp else 8.dp, // Smaller top padding if no pinned values - bottom = 8.dp - ), - verticalArrangement = Arrangement.spacedBy(10.dp) + modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 0.dp, bottom = 0.dp), + verticalArrangement = Arrangement.spacedBy(0.dp) ) { nonPinnedValues.forEach { valueWithTrend -> - MeasurementValueRow(valueWithTrend) + MeasurementRowExpandable( + valueWithTrend = valueWithTrend, + userEvaluationContext = userEvaluationContext, + measuredAtMillis = measuredAtMillis, + expandedTypeIds = expandedTypeIds + ) } } } } + // Footer: Expand/Collapse TextButton (only if there are non-pinned values and also pinned values, // or if there are non-pinned values and it's not the default expanded state for only non-pinned). - if (nonPinnedValues.isNotEmpty() && (pinnedValues.isNotEmpty() || !isExpanded) ) { + if (nonPinnedValues.isNotEmpty() && (pinnedValues.isNotEmpty() || !isExpanded)) { // Show divider if the expandable section is visible or if pinned items are present (button will always be there) if (isExpanded || pinnedValues.isNotEmpty()) { - HorizontalDivider(modifier = Modifier.padding( - top = if (isExpanded && nonPinnedValues.isNotEmpty()) 4.dp else if (pinnedValues.isNotEmpty()) 8.dp else 0.dp, - bottom = 0.dp - )) + HorizontalDivider( + modifier = Modifier.padding( + top = if (isExpanded && nonPinnedValues.isNotEmpty()) 4.dp else if (pinnedValues.isNotEmpty()) 8.dp else 0.dp, + bottom = 0.dp + ) + ) } TextButton( @@ -790,69 +818,141 @@ fun MeasurementCard( } /** - * A row Composable that displays a single measurement value, including its type icon, - * name, value, unit, and trend indicator if applicable. + * Displays one measurement row: icon + name + (optional) trend on the left, + * value and an evaluation symbol on the right. * - * @param valueWithTrend The [ValueWithDifference] object containing the current value, - * type information, difference from a previous value, and trend. + * Symbol rules: + * - ▲ / ▼ / ●: based on the evaluation state (HIGH / LOW / NORMAL/UNDEFINED) + * - ! (error color): shown if there is no matching age band at measurement time + * OR if a percentage value is outside a plausible range (0–100%). + * + * Note: + * - Non-numeric types (TEXT/DATE/TIME) are not evaluated (show ● if not flagged). */ @Composable -fun MeasurementValueRow(valueWithTrend: ValueWithDifference) { +fun MeasurementValueRow( + valueWithTrend: ValueWithDifference, + userEvaluationContext: UserEvaluationContext?, + measuredAtMillis: Long +) { val type = valueWithTrend.currentValue.type - val originalValue = valueWithTrend.currentValue.value // This is Measurement.Value object + val originalValue = valueWithTrend.currentValue.value val difference = valueWithTrend.difference val trend = valueWithTrend.trend + val unitName = type.unit.displayName + // Localized display value for each input type val displayValue = when (type.inputType) { InputFieldType.FLOAT -> originalValue.floatValue?.let { "%.1f".format(Locale.getDefault(), it) } - InputFieldType.INT -> originalValue.intValue?.toString() - InputFieldType.TEXT -> originalValue.textValue - InputFieldType.DATE -> originalValue.dateValue?.let { - SimpleDateFormat("dd.MM.yyyy", Locale.getDefault()).format(Date(it)) + InputFieldType.INT -> originalValue.intValue?.toString() + InputFieldType.TEXT -> originalValue.textValue + InputFieldType.DATE -> originalValue.dateValue?.let { + DateFormat.getDateInstance(DateFormat.SHORT, Locale.getDefault()).format(Date(it)) } - InputFieldType.TIME -> originalValue.dateValue?.let { - SimpleDateFormat("HH:mm", Locale.getDefault()).format(Date(it)) + InputFieldType.TIME -> originalValue.dateValue?.let { + DateFormat.getTimeInstance(DateFormat.SHORT, Locale.getDefault()).format(Date(it)) } - } ?: "-" // Default to dash if value is null + } ?: "-" val context = LocalContext.current - val iconMeasurementType = remember(type.icon) {type.icon } + val iconMeasurementType = remember(type.icon) { type.icon } + + // Extract numeric value only for evaluable numeric types + val numeric: Float? = when (type.inputType) { + InputFieldType.FLOAT -> originalValue.floatValue + InputFieldType.INT -> originalValue.intValue?.toFloat() + else -> null + } + + // Compute evaluation if possible + val evalResult = remember(valueWithTrend, userEvaluationContext, measuredAtMillis) { + if (userEvaluationContext != null && numeric != null) { + MeasurementEvaluator.evaluate( + typeKey = type.key, + value = numeric, + userEvaluationContext = userEvaluationContext, + measuredAtMillis = measuredAtMillis + ) + } else null + } + + // Flag 1: no matching age band (limits are negative) + val noAgeBand: Boolean = evalResult?.let { it.lowLimit < 0f || it.highLimit < 0f } ?: false + + // Flag 2: percent outside a plausible range (0..100) + val plausible = plausiblePercentRangeFor(type.key) + val outOfPlausibleRange = + if (numeric == null) { + false + } else { + plausible?.let { numeric < it.start || numeric > it.endInclusive } + ?: (unitName == "%" && (numeric < 0f || numeric > 100f)) // Fallback + } + + val flagged = noAgeBand || outOfPlausibleRange + + // Base evaluation state (falls back to UNDEFINED when not evaluable) + val evalState = evalResult?.state ?: EvaluationState.UNDEFINED + + // Symbol selection + val evalSymbol = if (flagged) { + "!" + } else { + when (evalState) { + EvaluationState.LOW -> "▼" + EvaluationState.NORMAL -> "●" + EvaluationState.HIGH -> "▲" + EvaluationState.UNDEFINED -> "●" + } + } + + // Symbol color: error for "!", otherwise mapped from eval state + val evalColor = if (flagged) { + MaterialTheme.colorScheme.error + } else { + evalState.toColor() + } Row( - modifier = Modifier.fillMaxWidth(), + modifier = Modifier + .fillMaxWidth() + .height(IntrinsicSize.Min) + .padding(vertical = 6.dp), verticalAlignment = Alignment.CenterVertically ) { - // Left part: Icon and Type Name + // Left side: icon + labels Row( - modifier = Modifier.weight(1f), // Takes available space, pushing value & trend to the right + modifier = Modifier.weight(1f), verticalAlignment = Alignment.CenterVertically ) { RoundMeasurementIcon( icon = iconMeasurementType, - backgroundTint = Color(type.color), + backgroundTint = Color(type.color) ) Spacer(modifier = Modifier.width(12.dp)) Column(verticalArrangement = Arrangement.Center) { Text( text = type.getDisplayName(context), style = MaterialTheme.typography.titleSmall, - maxLines = 1, + maxLines = 1 ) + + // Show trend only for numeric types with a difference if (difference != null && trend != Trend.NOT_APPLICABLE) { Spacer(modifier = Modifier.height(1.dp)) Row(verticalAlignment = Alignment.CenterVertically) { val trendIconVector = when (trend) { - Trend.UP -> Icons.Filled.ArrowUpward + Trend.UP -> Icons.Filled.ArrowUpward Trend.DOWN -> Icons.Filled.ArrowDownward Trend.NONE -> null - else -> null + else -> null } - val subtleGrayColor = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f) + val subtle = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f) if (trendIconVector != null) { Icon( imageVector = trendIconVector, contentDescription = trend.name, - tint = subtleGrayColor, + tint = subtle, modifier = Modifier.size(12.dp) ) Spacer(modifier = Modifier.width(3.dp)) @@ -861,23 +961,218 @@ fun MeasurementValueRow(valueWithTrend: ValueWithDifference) { text = (if (difference > 0 && trend != Trend.NONE) "+" else "") + when (type.inputType) { InputFieldType.FLOAT -> "%.1f".format(Locale.getDefault(), difference) - InputFieldType.INT -> difference.toInt().toString() - else -> "" - } + " ${type.unit.displayName}", + InputFieldType.INT -> difference.toInt().toString() + else -> "" + } + " $unitName", style = MaterialTheme.typography.bodySmall, - color = subtleGrayColor + color = subtle ) } } else if (type.inputType == InputFieldType.FLOAT || type.inputType == InputFieldType.INT) { + // Keep vertical spacing consistent when no trend is shown Spacer(modifier = Modifier.height((MaterialTheme.typography.bodySmall.fontSize.value + 2).dp)) } } } - Text( - text = "$displayValue ${type.unit.displayName}", - style = MaterialTheme.typography.bodyLarge, - textAlign = TextAlign.End, + + // Right side: value + evaluation symbol + Row( + verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(start = 8.dp) - ) + ) { + Text( + text = "$displayValue $unitName", + style = MaterialTheme.typography.bodyLarge, + textAlign = TextAlign.End + ) + Spacer(Modifier.width(6.dp)) + Text( + text = evalSymbol, + color = evalColor, + style = MaterialTheme.typography.bodyLarge + ) + } } } + + +/** + * Small, prominent banner for evaluation problems (e.g., no age band or implausible value). + * + * @param message Localized message to display inside the banner. + */ +@Composable +private fun EvaluationErrorBanner(message: String) { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(8.dp) + .clip(MaterialTheme.shapes.medium) + .background(MaterialTheme.colorScheme.errorContainer) + .padding(12.dp) + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + imageVector = Icons.Filled.Error, + contentDescription = null, + tint = MaterialTheme.colorScheme.onErrorContainer + ) + Spacer(Modifier.width(8.dp)) + Text( + text = message, + color = MaterialTheme.colorScheme.onErrorContainer, + style = MaterialTheme.typography.bodyMedium + ) + } + } +} + + +/** + * Returns a broad **plausible** percentage range for selected measurement types. + * + * This is **not** a clinical reference band. It’s only used to catch obviously + * incorrect values (e.g., sensor glitches, unit mix-ups) before attempting + * a proper evaluation. The ranges are intentionally wide. + * + * @param typeKey The measurement type to check. + * @return A closed percent range [min .. max] if the metric is percentage-based and supported, + * or `null` if no generic plausibility range is defined for this type. + */ +private fun plausiblePercentRangeFor(typeKey: MeasurementTypeKey): ClosedFloatingPointRange? = + when (typeKey) { + MeasurementTypeKey.WATER -> 35f..75f + MeasurementTypeKey.BODY_FAT -> 3f..70f + MeasurementTypeKey.MUSCLE -> 15f..60f + else -> null + } + + +/** + * One measurement row that can expand to show a gauge or an info banner. + * + * Behavior: + * - If a normal evaluation is possible, the row expands to a LinearGauge. + * - If no age band exists or the value is outside a plausible range, the row expands to an info banner. + * - The clickable state of the row follows the above rules (only clickable when there is something meaningful to show). + * + * @param valueWithTrend The value and meta info for this row. + * @param userEvaluationContext The context needed to evaluate the value (gender, age, etc.); can be null. + * @param measuredAtMillis Timestamp of the measurement (used by the evaluator). + * @param expandedTypeIds State map holding expand/collapse flags per measurement type id. + * @param modifier Optional modifier for the container column. + * @param gaugeHeightDp Height of the gauge when shown. + */ +@Composable +fun MeasurementRowExpandable( + valueWithTrend: ValueWithDifference, + userEvaluationContext: UserEvaluationContext?, + measuredAtMillis: Long, + expandedTypeIds: MutableMap, + modifier: Modifier = Modifier, + gaugeHeightDp: Dp = 80.dp, +) { + val type = valueWithTrend.currentValue.type + + // Extract numeric value for evaluation / plausibility checks + val numeric: Float? = when (type.inputType) { + InputFieldType.FLOAT -> valueWithTrend.currentValue.value.floatValue + InputFieldType.INT -> valueWithTrend.currentValue.value.intValue?.toFloat() + else -> null + } + + // Run evaluation (or keep null when not possible) + val evalResult = remember(valueWithTrend, userEvaluationContext, measuredAtMillis) { + if (userEvaluationContext == null || numeric == null) { + null + } else { + MeasurementEvaluator.evaluate( + typeKey = type.key, + value = numeric, + userEvaluationContext = userEvaluationContext, + measuredAtMillis = measuredAtMillis + ) + } + } + + // Special cases: + // 1) No age band available -> evaluator returns negative limits + val noAgeBand = evalResult?.let { it.lowLimit < 0f || it.highLimit < 0f } ?: false + + // 2) Implausible value for percentage-based metrics + val unitName = type.unit.displayName + val plausible = plausiblePercentRangeFor(type.key) + val outOfPlausibleRange = + if (numeric == null) { + false + } else { + plausible?.let { numeric < it.start || numeric > it.endInclusive } + ?: (unitName == "%" && (numeric < 0f || numeric > 100f)) + } + + // Expand is allowed when: + // - a normal evaluation exists (valid limits), OR + // - we have one of the special cases (to show the info banner) + val canExpand = (evalResult != null && !noAgeBand) || noAgeBand || outOfPlausibleRange + + Column(modifier) { + // The main row – clickable only when `canExpand` is true. + Box( + modifier = Modifier + .fillMaxWidth() + .clickable(enabled = canExpand) { + val cur = expandedTypeIds[type.id] ?: false + expandedTypeIds[type.id] = !cur + } + ) { + // Uses your existing row (with ●/▲/▼ or ! logic inside). + MeasurementValueRow( + valueWithTrend = valueWithTrend, + userEvaluationContext = userEvaluationContext, + measuredAtMillis = measuredAtMillis + ) + } + + val unit = type.unit.displayName + + // Expanded content: + AnimatedVisibility(visible = canExpand && (expandedTypeIds[type.id] == true)) { + when { + noAgeBand -> { + EvaluationErrorBanner( + message = stringResource(R.string.eval_no_age_band) + ) + } + outOfPlausibleRange -> { + val plausible = plausiblePercentRangeFor(type.key) ?: (0f..100f) + EvaluationErrorBanner( + message = stringResource( + R.string.eval_out_of_plausible_range_percent, + plausible.start, + plausible.endInclusive + ) + ) + } + // Normal evaluation → show gauge + evalResult != null -> { + Column( + Modifier.padding(start = 16.dp, end = 16.dp, top = 6.dp, bottom = 2.dp) + ) { + LinearGauge( + value = evalResult.value, + lowLimit = if (evalResult.lowLimit < 0f) null else evalResult.lowLimit, + highLimit = evalResult.highLimit, + modifier = Modifier + .fillMaxWidth() + .height(gaugeHeightDp), + labelProvider = { v -> + String.format(Locale.getDefault(), "%,.1f %s", v, unit) + } + ) + } + } + } + } + } +} + diff --git a/android_app/app/src/main/java/com/health/openscale/ui/screen/settings/UserSettingsScreen.kt b/android_app/app/src/main/java/com/health/openscale/ui/screen/settings/UserSettingsScreen.kt index 8fc43360..79738f68 100644 --- a/android_app/app/src/main/java/com/health/openscale/ui/screen/settings/UserSettingsScreen.kt +++ b/android_app/app/src/main/java/com/health/openscale/ui/screen/settings/UserSettingsScreen.kt @@ -92,7 +92,7 @@ fun UserSettingsScreen( items(users) { user -> // Calculate age. This will be recalculated if user.birthDate changes. val age = remember(user.birthDate) { - CalculationUtil.dateToAge(user.birthDate) + CalculationUtil.ageOn(System.currentTimeMillis(), user.birthDate) } ListItem( 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 43c014ab..aca4738d 100644 --- a/android_app/app/src/main/res/values-de/strings.xml +++ b/android_app/app/src/main/res/values-de/strings.xml @@ -434,6 +434,10 @@ Gesamte Datenbank wurde gelöscht. Fehler beim Löschen der gesamten Datenbank. + + Keine Bewertung möglich: Kein passender Altersbereich zum Messzeitpunkt. + Auffälliger Wert: außerhalb des plausiblen Bereichs (%1$.0f–%2$.0f%%). + Bluetooth-Berechtigungen werden zum Scannen von Geräten benötigt. Bluetooth ist deaktiviert. Bitte aktiviere es, um nach Geräten zu scannen. diff --git a/android_app/app/src/main/res/values/strings.xml b/android_app/app/src/main/res/values/strings.xml index 36fa0310..9dbdb2a1 100644 --- a/android_app/app/src/main/res/values/strings.xml +++ b/android_app/app/src/main/res/values/strings.xml @@ -436,6 +436,10 @@ Entire database has been deleted. Error deleting entire database. + + No evaluation possible: No matching age band at the time of measurement. + Unusual value: outside the plausible range (%1$.0f–%2$.0f%%). + Bluetooth permissions are required to scan for devices. Bluetooth is disabled. Please enable it to scan for devices. diff --git a/android_app/gradle/libs.versions.toml b/android_app/gradle/libs.versions.toml index 20f55709..8450063b 100644 --- a/android_app/gradle/libs.versions.toml +++ b/android_app/gradle/libs.versions.toml @@ -17,6 +17,7 @@ documentfile = "1.1.0" composeCharts = "2.1.3" composeReorderable = "2.5.1" compose-material = "1.7.8" +constraintlayout-compose = "1.1.1" kotlinCsv = "1.10.0" blessedKotlin = "3.0.9" blessedJava = "2.5.2" @@ -38,6 +39,7 @@ androidx-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-man androidx-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" } androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigation" } androidx-material3 = { group = "androidx.compose.material3", name = "material3" } +androidx-constraintlayout-compose = { group = "androidx.constraintlayout", name = "constraintlayout-compose", version.ref = "constraintlayout-compose" } androidx-room-runtime = { module = "androidx.room:room-runtime", version.ref = "room" } androidx-room-ktx = { module = "androidx.room:room-ktx", version.ref = "room" } androidx-room-compiler = { module = "androidx.room:room-compiler", version.ref = "room" }