1
0
mirror of https://github.com/oliexdev/openScale.git synced 2025-08-25 09:30:51 +02:00

Introduces a new framework for evaluating measurement values against reference ranges and displays this information in the UI.

This commit is contained in:
oliexdev
2025-08-18 16:52:10 +02:00
parent 220afffb33
commit cf6d834db7
14 changed files with 1028 additions and 122 deletions

View File

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

View File

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

View File

@@ -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 (170%).")
fatPercentage
}
}

View File

@@ -0,0 +1,145 @@
/*
* 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.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)
}

View File

@@ -0,0 +1,213 @@
/*
* 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.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<AgeBand>) : 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 P25P75 (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 }
)
}
}

View File

@@ -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)
}
/**

View File

@@ -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<ValueWithDifference>
)
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<UserEvaluationContext?> =
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<MeasurementValue>) {

View File

@@ -0,0 +1,214 @@
/*
* 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.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
)
}
}
}

View File

@@ -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<Int?>(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
)
}
}

View File

@@ -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<ValueWithDifference>,
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<Int, Boolean>() }
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 (0100%).
*
* 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. Its 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<Float>? =
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<Int, Boolean>,
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)
}
)
}
}
}
}
}
}

View File

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

View File

@@ -434,6 +434,10 @@
<string name="delete_db_successful">Gesamte Datenbank wurde gelöscht.</string>
<string name="delete_db_error">Fehler beim Löschen der gesamten Datenbank.</string>
<!-- Evaluation Nachrichten -->
<string name="eval_no_age_band">Keine Bewertung möglich: Kein passender Altersbereich zum Messzeitpunkt.</string>
<string name="eval_out_of_plausible_range_percent">Auffälliger Wert: außerhalb des plausiblen Bereichs (%1$.0f%2$.0f%%).</string>
<!-- BluetoothViewModel Snackbar Messages -->
<string name="bt_snackbar_permissions_required_to_scan">Bluetooth-Berechtigungen werden zum Scannen von Geräten benötigt.</string>
<string name="bt_snackbar_bluetooth_disabled_to_scan">Bluetooth ist deaktiviert. Bitte aktiviere es, um nach Geräten zu scannen.</string>

View File

@@ -436,6 +436,10 @@
<string name="delete_db_successful">Entire database has been deleted.</string>
<string name="delete_db_error">Error deleting entire database.</string>
<!-- Evaluation Messages -->
<string name="eval_no_age_band">No evaluation possible: No matching age band at the time of measurement.</string>
<string name="eval_out_of_plausible_range_percent">Unusual value: outside the plausible range (%1$.0f%2$.0f%%).</string>
<!-- BluetoothViewModel Snackbar Messages -->
<string name="bt_snackbar_permissions_required_to_scan">Bluetooth permissions are required to scan for devices.</string>
<string name="bt_snackbar_bluetooth_disabled_to_scan">Bluetooth is disabled. Please enable it to scan for devices.</string>

View File

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