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:
@@ -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)
|
||||
|
@@ -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
|
||||
}
|
||||
}
|
@@ -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
|
||||
}
|
||||
}
|
||||
|
@@ -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)
|
||||
}
|
@@ -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 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 }
|
||||
)
|
||||
}
|
||||
}
|
@@ -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)
|
||||
}
|
||||
|
||||
/**
|
||||
|
@@ -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>) {
|
||||
|
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
@@ -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 (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<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)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@@ -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(
|
||||
|
@@ -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>
|
||||
|
@@ -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>
|
||||
|
@@ -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" }
|
||||
|
Reference in New Issue
Block a user