mirror of
https://github.com/oliexdev/openScale.git
synced 2025-08-25 17:42:29 +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.graphics)
|
||||||
implementation(libs.androidx.ui.tooling.preview)
|
implementation(libs.androidx.ui.tooling.preview)
|
||||||
implementation(libs.androidx.material3)
|
implementation(libs.androidx.material3)
|
||||||
|
implementation(libs.androidx.constraintlayout.compose)
|
||||||
implementation(libs.androidx.navigation.compose)
|
implementation(libs.androidx.navigation.compose)
|
||||||
implementation(libs.androidx.worker)
|
implementation(libs.androidx.worker)
|
||||||
implementation(libs.androidx.documentfile)
|
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.StackedLineChart
|
||||||
import androidx.compose.material.icons.filled.Timer
|
import androidx.compose.material.icons.filled.Timer
|
||||||
import androidx.compose.material.icons.filled.WarningAmber
|
import androidx.compose.material.icons.filled.WarningAmber
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.vector.ImageVector
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
import com.health.openscale.R
|
import com.health.openscale.R
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
@@ -364,4 +365,18 @@ enum class BackupInterval {
|
|||||||
MONTHLY -> context.getString(R.string.interval_monthly)
|
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
|
// User's height is assumed to be stored in CM in the User object
|
||||||
val userHeightCm = user.heightCm
|
val userHeightCm = user.heightCm
|
||||||
|
|
||||||
|
val ageAtMeasurementYears = CalculationUtil.ageOn(
|
||||||
|
dateMillis = measurement.timestamp,
|
||||||
|
birthDateMillis = user.birthDate
|
||||||
|
)
|
||||||
|
|
||||||
// --- PERFORM DERIVED VALUE CALCULATIONS ---
|
// --- PERFORM DERIVED VALUE CALCULATIONS ---
|
||||||
// Pass the converted values (e.g., weightKg, waistCm) to the processing functions
|
// 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) }
|
processLbmCalculation(weightKg, bodyFatPercentage).also { saveOrUpdateDerivedValue(it, MeasurementTypeKey.LBM) }
|
||||||
processWhrCalculation(waistCm, hipsCm).also { saveOrUpdateDerivedValue(it, MeasurementTypeKey.WHR) }
|
processWhrCalculation(waistCm, hipsCm).also { saveOrUpdateDerivedValue(it, MeasurementTypeKey.WHR) }
|
||||||
processWhtrCalculation(waistCm, userHeightCm).also { saveOrUpdateDerivedValue(it, MeasurementTypeKey.WHTR) }
|
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)
|
saveOrUpdateDerivedValue(bmr, MeasurementTypeKey.BMR)
|
||||||
// TDEE calculation depends on the BMR result
|
|
||||||
processTDEECalculation(bmr, user.activityLevel).also { saveOrUpdateDerivedValue(it, MeasurementTypeKey.TDEE) }
|
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")
|
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? {
|
private fun processBmrCalculation(
|
||||||
LogManager.v(CALC_PROCESS_TAG, "Processing BMR for user ${user.id}: weight=$weightKg kg")
|
weightKg: Float?,
|
||||||
val heightCm = user.heightCm
|
heightCm: Float?,
|
||||||
val birthDateTimestamp = user.birthDate
|
ageYears: Int,
|
||||||
val gender = user.gender
|
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 ||
|
if (weightKg == null || weightKg <= 0f ||
|
||||||
heightCm == null || heightCm <= 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
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
val ageYears = CalculationUtil.dateToAge(birthDateTimestamp)
|
return when (gender) {
|
||||||
LogManager.v(CALC_PROCESS_TAG, "Calculated age for BMR: $ageYears years")
|
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
|
||||||
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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -488,13 +497,18 @@ class DatabaseRepository(
|
|||||||
return bmr * activityFactor
|
return bmr * activityFactor
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
private fun processFatCaliperCalculation(
|
private fun processFatCaliperCalculation(
|
||||||
caliper1Cm: Float?,
|
caliper1Cm: Float?,
|
||||||
caliper2Cm: Float?,
|
caliper2Cm: Float?,
|
||||||
caliper3Cm: Float?,
|
caliper3Cm: Float?,
|
||||||
user: User
|
ageYears: Int,
|
||||||
|
gender: GenderType
|
||||||
): Float? {
|
): 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 ||
|
if (caliper1Cm == null || caliper1Cm <= 0f ||
|
||||||
caliper2Cm == null || caliper2Cm <= 0f ||
|
caliper2Cm == null || caliper2Cm <= 0f ||
|
||||||
@@ -504,18 +518,16 @@ class DatabaseRepository(
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
val gender = user.gender
|
|
||||||
val ageYears = CalculationUtil.dateToAge(user.birthDate)
|
|
||||||
|
|
||||||
if (ageYears <= 0) {
|
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
|
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
|
val sumSkinfoldsMm = (caliper1Cm + caliper2Cm + caliper3Cm) * 10.0f
|
||||||
LogManager.v(CALC_PROCESS_TAG, "Sum of skinfolds (S): $sumSkinfoldsMm mm")
|
LogManager.v(CALC_PROCESS_TAG, "Sum of skinfolds (S): $sumSkinfoldsMm mm")
|
||||||
|
|
||||||
|
// Choose constants based on gender
|
||||||
val k0: Float
|
val k0: Float
|
||||||
val k1: Float
|
val k1: Float
|
||||||
val k2: 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")
|
LogManager.v(CALC_PROCESS_TAG, "Calculated Body Density (BD): $bodyDensity")
|
||||||
|
|
||||||
if (bodyDensity <= 0f) {
|
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
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
val fatPercentage = (4.95f / bodyDensity - 4.5f) * 100.0f
|
val fatPercentage = (4.95f / bodyDensity - 4.5f) * 100.0f
|
||||||
LogManager.v(CALC_PROCESS_TAG, "Calculated Fat Percentage from BD: $fatPercentage %")
|
LogManager.v(CALC_PROCESS_TAG, "Calculated Fat Percentage from BD: $fatPercentage %")
|
||||||
|
|
||||||
return if (fatPercentage in 1.0f..70.0f) {
|
return fatPercentage.takeIf { it in 1.0f..70.0f } ?: run {
|
||||||
fatPercentage
|
LogManager.w(CALC_PROCESS_TAG, "Calculated Fat Percentage ($fatPercentage%) is outside the expected physiological range (1–70%).")
|
||||||
} else {
|
|
||||||
LogManager.w(CALC_PROCESS_TAG, "Calculated Fat Percentage ($fatPercentage%) is outside the expected physiological range (1-70%). User ID: ${user.id}")
|
|
||||||
fatPercentage
|
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
|
import java.util.Locale
|
||||||
|
|
||||||
object CalculationUtil {
|
object CalculationUtil {
|
||||||
fun dateToAge(birthDateMillis: Long): Int {
|
fun ageOn(dateMillis: Long, birthDateMillis: Long): Int {
|
||||||
val birthDate = Instant.ofEpochMilli(birthDateMillis)
|
val birth = Instant.ofEpochMilli(birthDateMillis).atZone(ZoneId.systemDefault()).toLocalDate()
|
||||||
.atZone(ZoneId.systemDefault())
|
val onDate = Instant.ofEpochMilli(dateMillis).atZone(ZoneId.systemDefault()).toLocalDate()
|
||||||
.toLocalDate()
|
return Period.between(birth, onDate).years.coerceAtLeast(0)
|
||||||
|
|
||||||
val today = LocalDate.now()
|
|
||||||
|
|
||||||
return Period.between(birthDate, today).years
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@@ -20,24 +20,16 @@ package com.health.openscale.ui.screen
|
|||||||
import android.app.Application
|
import android.app.Application
|
||||||
import android.content.ComponentName
|
import android.content.ComponentName
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.util.Log
|
|
||||||
import androidx.annotation.StringRes
|
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.material3.SnackbarDuration
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.geometry.isEmpty
|
|
||||||
import androidx.compose.ui.graphics.vector.ImageVector
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
import androidx.compose.ui.input.key.type
|
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
import androidx.core.graphics.values
|
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.ViewModelProvider
|
import androidx.lifecycle.ViewModelProvider
|
||||||
import androidx.lifecycle.application
|
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.health.openscale.R
|
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.InputFieldType
|
||||||
import com.health.openscale.core.data.Measurement
|
import com.health.openscale.core.data.Measurement
|
||||||
import com.health.openscale.core.data.MeasurementType
|
import com.health.openscale.core.data.MeasurementType
|
||||||
@@ -131,6 +123,12 @@ data class EnrichedMeasurement(
|
|||||||
val valuesWithTrend: List<ValueWithDifference>
|
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.
|
* Shared ViewModel for managing UI state and business logic accessible across multiple screens.
|
||||||
* It handles user selection, measurement data (CRUD operations, display, enrichment),
|
* 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)")
|
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 ---
|
// --- Measurement CRUD Operations ---
|
||||||
|
|
||||||
fun saveMeasurement(measurementToSave: Measurement, valuesToSave: List<MeasurementValue>) {
|
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
|
package com.health.openscale.ui.screen.graph
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.Close
|
import androidx.compose.material.icons.filled.Close
|
||||||
import androidx.compose.material.icons.filled.Edit
|
import androidx.compose.material.icons.filled.Edit
|
||||||
import androidx.compose.material3.*
|
import androidx.compose.material3.*
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.SheetValue
|
|
||||||
import androidx.compose.material3.ModalBottomSheet
|
import androidx.compose.material3.ModalBottomSheet
|
||||||
import androidx.compose.material3.rememberModalBottomSheetState
|
import androidx.compose.material3.rememberModalBottomSheetState
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
@@ -38,7 +36,6 @@ import androidx.navigation.NavController
|
|||||||
import com.health.openscale.R
|
import com.health.openscale.R
|
||||||
import com.health.openscale.core.data.Trend
|
import com.health.openscale.core.data.Trend
|
||||||
import com.health.openscale.core.database.UserPreferenceKeys
|
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.navigation.Routes
|
||||||
import com.health.openscale.ui.screen.SharedViewModel
|
import com.health.openscale.ui.screen.SharedViewModel
|
||||||
import com.health.openscale.ui.screen.ValueWithDifference
|
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.components.provideFilterTopBarAction
|
||||||
import com.health.openscale.ui.screen.overview.MeasurementValueRow
|
import com.health.openscale.ui.screen.overview.MeasurementValueRow
|
||||||
import java.text.DateFormat
|
import java.text.DateFormat
|
||||||
import java.text.SimpleDateFormat
|
|
||||||
import java.time.Instant
|
|
||||||
import java.time.ZoneId
|
|
||||||
import java.util.Date
|
import java.util.Date
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
|
|
||||||
@@ -62,6 +56,7 @@ fun GraphScreen(
|
|||||||
val isLoading by sharedViewModel.isBaseDataLoading.collectAsState()
|
val isLoading by sharedViewModel.isBaseDataLoading.collectAsState()
|
||||||
val allMeasurementsWithValues by sharedViewModel.allMeasurementsForSelectedUser.collectAsState()
|
val allMeasurementsWithValues by sharedViewModel.allMeasurementsForSelectedUser.collectAsState()
|
||||||
val selectedUserId by sharedViewModel.selectedUserId.collectAsState()
|
val selectedUserId by sharedViewModel.selectedUserId.collectAsState()
|
||||||
|
val userEvalContext by sharedViewModel.userEvaluationContext.collectAsState()
|
||||||
|
|
||||||
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = false)
|
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = false)
|
||||||
var sheetMeasurementId by rememberSaveable { mutableStateOf<Int?>(null) }
|
var sheetMeasurementId by rememberSaveable { mutableStateOf<Int?>(null) }
|
||||||
@@ -180,11 +175,13 @@ fun GraphScreen(
|
|||||||
|
|
||||||
visibleValues.forEach { v ->
|
visibleValues.forEach { v ->
|
||||||
MeasurementValueRow(
|
MeasurementValueRow(
|
||||||
ValueWithDifference(
|
valueWithTrend = ValueWithDifference(
|
||||||
currentValue = v,
|
currentValue = v,
|
||||||
difference = null,
|
difference = null,
|
||||||
trend = Trend.NOT_APPLICABLE
|
trend = Trend.NOT_APPLICABLE
|
||||||
)
|
),
|
||||||
|
userEvaluationContext = userEvalContext,
|
||||||
|
measuredAtMillis = mwv.measurement.timestamp
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -17,17 +17,16 @@
|
|||||||
*/
|
*/
|
||||||
package com.health.openscale.ui.screen.overview
|
package com.health.openscale.ui.screen.overview
|
||||||
|
|
||||||
import android.R.attr.targetId
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
import androidx.compose.animation.AnimatedVisibility
|
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.BorderStroke
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.IntrinsicSize
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
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.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.itemsIndexed
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.automirrored.filled.BluetoothSearching
|
import androidx.compose.material.icons.automirrored.filled.BluetoothSearching
|
||||||
import androidx.compose.material.icons.filled.Add
|
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.ExpandMore
|
||||||
import androidx.compose.material.icons.filled.PersonAdd
|
import androidx.compose.material.icons.filled.PersonAdd
|
||||||
import androidx.compose.material.icons.filled.PersonSearch
|
import androidx.compose.material.icons.filled.PersonSearch
|
||||||
import androidx.compose.material.icons.filled.QuestionMark
|
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.ButtonDefaults
|
import androidx.compose.material3.ButtonDefaults
|
||||||
import androidx.compose.material3.Card
|
import androidx.compose.material3.Card
|
||||||
@@ -75,6 +72,7 @@ import androidx.compose.runtime.LaunchedEffect
|
|||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateListOf
|
import androidx.compose.runtime.mutableStateListOf
|
||||||
|
import androidx.compose.runtime.mutableStateMapOf
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
@@ -85,19 +83,24 @@ import androidx.compose.ui.Modifier
|
|||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.res.painterResource
|
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.navigation.NavController
|
import androidx.navigation.NavController
|
||||||
import com.health.openscale.R
|
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.InputFieldType
|
||||||
|
import com.health.openscale.core.data.MeasurementTypeKey
|
||||||
import com.health.openscale.core.data.Trend
|
import com.health.openscale.core.data.Trend
|
||||||
import com.health.openscale.core.model.MeasurementWithValues
|
import com.health.openscale.core.model.MeasurementWithValues
|
||||||
import com.health.openscale.core.database.UserPreferenceKeys
|
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.components.RoundMeasurementIcon
|
||||||
import com.health.openscale.ui.navigation.Routes
|
import com.health.openscale.ui.navigation.Routes
|
||||||
import com.health.openscale.ui.screen.SharedViewModel
|
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.ValueWithDifference
|
||||||
import com.health.openscale.ui.screen.bluetooth.BluetoothViewModel
|
import com.health.openscale.ui.screen.bluetooth.BluetoothViewModel
|
||||||
import com.health.openscale.ui.screen.bluetooth.ConnectionStatus
|
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 com.health.openscale.ui.screen.components.provideFilterTopBarAction
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import java.text.DateFormat
|
||||||
import java.text.SimpleDateFormat
|
import java.text.SimpleDateFormat
|
||||||
import java.time.Instant
|
|
||||||
import java.time.ZoneId
|
|
||||||
import java.util.Date
|
import java.util.Date
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
|
|
||||||
@@ -316,6 +318,9 @@ fun OverviewScreen(
|
|||||||
(type.inputType == InputFieldType.FLOAT || type.inputType == InputFieldType.INT) // Ensure it's a plottable type
|
(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 ---
|
// --- End of reverted chart selection logic ---
|
||||||
|
|
||||||
val savedDeviceAddress by bluetoothViewModel.savedScaleAddress.collectAsState()
|
val savedDeviceAddress by bluetoothViewModel.savedScaleAddress.collectAsState()
|
||||||
@@ -463,7 +468,8 @@ fun OverviewScreen(
|
|||||||
MeasurementCard(
|
MeasurementCard(
|
||||||
measurementWithValues = enrichedItem.measurementWithValues,
|
measurementWithValues = enrichedItem.measurementWithValues,
|
||||||
processedValuesForDisplay = enrichedItem.valuesWithTrend,
|
processedValuesForDisplay = enrichedItem.valuesWithTrend,
|
||||||
onEdit = {
|
userEvaluationContext = userEvalContext,
|
||||||
|
onEdit = {
|
||||||
navController.navigate(
|
navController.navigate(
|
||||||
Routes.measurementDetail(
|
Routes.measurementDetail(
|
||||||
enrichedItem.measurementWithValues.measurement.id,
|
enrichedItem.measurementWithValues.measurement.id,
|
||||||
@@ -628,6 +634,7 @@ fun NoMeasurementsCard(navController: NavController, selectedUserId: Int?) {
|
|||||||
fun MeasurementCard(
|
fun MeasurementCard(
|
||||||
measurementWithValues: MeasurementWithValues,
|
measurementWithValues: MeasurementWithValues,
|
||||||
processedValuesForDisplay: List<ValueWithDifference>,
|
processedValuesForDisplay: List<ValueWithDifference>,
|
||||||
|
userEvaluationContext: UserEvaluationContext?,
|
||||||
onEdit: () -> Unit,
|
onEdit: () -> Unit,
|
||||||
onDelete: () -> Unit,
|
onDelete: () -> Unit,
|
||||||
isHighlighted: Boolean = false
|
isHighlighted: Boolean = false
|
||||||
@@ -635,6 +642,9 @@ fun MeasurementCard(
|
|||||||
val highlightColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.12f)
|
val highlightColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.12f)
|
||||||
val highlightBorder = BorderStroke(1.dp, MaterialTheme.colorScheme.primary)
|
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) {
|
val dateFormatted = remember(measurementWithValues.measurement.timestamp) {
|
||||||
SimpleDateFormat("E, dd.MM.yyyy HH:mm", Locale.getDefault())
|
SimpleDateFormat("E, dd.MM.yyyy HH:mm", Locale.getDefault())
|
||||||
.format(Date(measurementWithValues.measurement.timestamp))
|
.format(Date(measurementWithValues.measurement.timestamp))
|
||||||
@@ -653,8 +663,6 @@ fun MeasurementCard(
|
|||||||
val allActiveProcessedValues = remember(processedValuesForDisplay) {
|
val allActiveProcessedValues = remember(processedValuesForDisplay) {
|
||||||
processedValuesForDisplay.filter { it.currentValue.type.isEnabled }
|
processedValuesForDisplay.filter { it.currentValue.type.isEnabled }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
Card(
|
Card(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
border = if (isHighlighted) highlightBorder else null,
|
border = if (isHighlighted) highlightBorder else null,
|
||||||
@@ -679,19 +687,26 @@ fun MeasurementCard(
|
|||||||
modifier = Modifier.weight(1f) // Date takes available space
|
modifier = Modifier.weight(1f) // Date takes available space
|
||||||
)
|
)
|
||||||
val iconButtonSize = 36.dp // Standard size for action icons
|
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)) {
|
IconButton(onClick = onEdit, modifier = Modifier.size(iconButtonSize)) {
|
||||||
Icon(
|
Icon(
|
||||||
Icons.Default.Edit,
|
Icons.Default.Edit,
|
||||||
contentDescription = stringResource(R.string.action_edit_measurement_desc, dateFormatted),
|
contentDescription = stringResource(
|
||||||
|
R.string.action_edit_measurement_desc,
|
||||||
|
dateFormatted
|
||||||
|
),
|
||||||
tint = actionIconColor
|
tint = actionIconColor
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
IconButton(onClick = onDelete, modifier = Modifier.size(iconButtonSize)) {
|
IconButton(onClick = onDelete, modifier = Modifier.size(iconButtonSize)) {
|
||||||
Icon(
|
Icon(
|
||||||
Icons.Default.Delete,
|
Icons.Default.Delete,
|
||||||
contentDescription = stringResource(R.string.action_delete_measurement_desc, dateFormatted),
|
contentDescription = stringResource(
|
||||||
|
R.string.action_delete_measurement_desc,
|
||||||
|
dateFormatted
|
||||||
|
),
|
||||||
tint = actionIconColor
|
tint = actionIconColor
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -699,7 +714,10 @@ fun MeasurementCard(
|
|||||||
// Conditional expand/collapse icon button for non-pinned values,
|
// 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)
|
// only shown if there are non-pinned values and no pinned values (to avoid duplicate expand button logic)
|
||||||
if (nonPinnedValues.isNotEmpty() && pinnedValues.isEmpty()) {
|
if (nonPinnedValues.isNotEmpty() && pinnedValues.isEmpty()) {
|
||||||
IconButton(onClick = { isExpanded = !isExpanded }, modifier = Modifier.size(iconButtonSize)) {
|
IconButton(
|
||||||
|
onClick = { isExpanded = !isExpanded },
|
||||||
|
modifier = Modifier.size(iconButtonSize)
|
||||||
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = if (isExpanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore,
|
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)
|
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()) {
|
if (pinnedValues.isNotEmpty()) {
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
Column(verticalArrangement = Arrangement.spacedBy(0.dp)) {
|
||||||
pinnedValues.forEach { valueWithTrend ->
|
pinnedValues.forEach { valueWithTrend ->
|
||||||
MeasurementValueRow(valueWithTrend)
|
MeasurementRowExpandable(
|
||||||
|
valueWithTrend = valueWithTrend,
|
||||||
|
userEvaluationContext = userEvaluationContext,
|
||||||
|
measuredAtMillis = measuredAtMillis,
|
||||||
|
expandedTypeIds = expandedTypeIds
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// Animated section for non-pinned measurement values (collapsible)
|
// Animated section for non-pinned measurement values (collapsible)
|
||||||
if (nonPinnedValues.isNotEmpty()) {
|
if (nonPinnedValues.isNotEmpty()) {
|
||||||
AnimatedVisibility(visible = isExpanded || pinnedValues.isEmpty()) { // Also visible if no pinned values and not expanded (default state)
|
AnimatedVisibility(visible = isExpanded || pinnedValues.isEmpty()) { // Also visible if no pinned values and not expanded (default state)
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier.padding(
|
modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 0.dp, bottom = 0.dp),
|
||||||
start = 16.dp, end = 16.dp,
|
verticalArrangement = Arrangement.spacedBy(0.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)
|
|
||||||
) {
|
) {
|
||||||
nonPinnedValues.forEach { valueWithTrend ->
|
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,
|
// 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).
|
// 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)
|
// Show divider if the expandable section is visible or if pinned items are present (button will always be there)
|
||||||
if (isExpanded || pinnedValues.isNotEmpty()) {
|
if (isExpanded || pinnedValues.isNotEmpty()) {
|
||||||
HorizontalDivider(modifier = Modifier.padding(
|
HorizontalDivider(
|
||||||
top = if (isExpanded && nonPinnedValues.isNotEmpty()) 4.dp else if (pinnedValues.isNotEmpty()) 8.dp else 0.dp,
|
modifier = Modifier.padding(
|
||||||
bottom = 0.dp
|
top = if (isExpanded && nonPinnedValues.isNotEmpty()) 4.dp else if (pinnedValues.isNotEmpty()) 8.dp else 0.dp,
|
||||||
))
|
bottom = 0.dp
|
||||||
|
)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
TextButton(
|
TextButton(
|
||||||
@@ -790,69 +818,141 @@ fun MeasurementCard(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A row Composable that displays a single measurement value, including its type icon,
|
* Displays one measurement row: icon + name + (optional) trend on the left,
|
||||||
* name, value, unit, and trend indicator if applicable.
|
* value and an evaluation symbol on the right.
|
||||||
*
|
*
|
||||||
* @param valueWithTrend The [ValueWithDifference] object containing the current value,
|
* Symbol rules:
|
||||||
* type information, difference from a previous value, and trend.
|
* - ▲ / ▼ / ●: 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
|
@Composable
|
||||||
fun MeasurementValueRow(valueWithTrend: ValueWithDifference) {
|
fun MeasurementValueRow(
|
||||||
|
valueWithTrend: ValueWithDifference,
|
||||||
|
userEvaluationContext: UserEvaluationContext?,
|
||||||
|
measuredAtMillis: Long
|
||||||
|
) {
|
||||||
val type = valueWithTrend.currentValue.type
|
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 difference = valueWithTrend.difference
|
||||||
val trend = valueWithTrend.trend
|
val trend = valueWithTrend.trend
|
||||||
|
val unitName = type.unit.displayName
|
||||||
|
|
||||||
|
// Localized display value for each input type
|
||||||
val displayValue = when (type.inputType) {
|
val displayValue = when (type.inputType) {
|
||||||
InputFieldType.FLOAT -> originalValue.floatValue?.let { "%.1f".format(Locale.getDefault(), it) }
|
InputFieldType.FLOAT -> originalValue.floatValue?.let { "%.1f".format(Locale.getDefault(), it) }
|
||||||
InputFieldType.INT -> originalValue.intValue?.toString()
|
InputFieldType.INT -> originalValue.intValue?.toString()
|
||||||
InputFieldType.TEXT -> originalValue.textValue
|
InputFieldType.TEXT -> originalValue.textValue
|
||||||
InputFieldType.DATE -> originalValue.dateValue?.let {
|
InputFieldType.DATE -> originalValue.dateValue?.let {
|
||||||
SimpleDateFormat("dd.MM.yyyy", Locale.getDefault()).format(Date(it))
|
DateFormat.getDateInstance(DateFormat.SHORT, Locale.getDefault()).format(Date(it))
|
||||||
}
|
}
|
||||||
InputFieldType.TIME -> originalValue.dateValue?.let {
|
InputFieldType.TIME -> originalValue.dateValue?.let {
|
||||||
SimpleDateFormat("HH:mm", Locale.getDefault()).format(Date(it))
|
DateFormat.getTimeInstance(DateFormat.SHORT, Locale.getDefault()).format(Date(it))
|
||||||
}
|
}
|
||||||
} ?: "-" // Default to dash if value is null
|
} ?: "-"
|
||||||
|
|
||||||
val context = LocalContext.current
|
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(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(IntrinsicSize.Min)
|
||||||
|
.padding(vertical = 6.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically
|
||||||
) {
|
) {
|
||||||
// Left part: Icon and Type Name
|
// Left side: icon + labels
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.weight(1f), // Takes available space, pushing value & trend to the right
|
modifier = Modifier.weight(1f),
|
||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically
|
||||||
) {
|
) {
|
||||||
RoundMeasurementIcon(
|
RoundMeasurementIcon(
|
||||||
icon = iconMeasurementType,
|
icon = iconMeasurementType,
|
||||||
backgroundTint = Color(type.color),
|
backgroundTint = Color(type.color)
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.width(12.dp))
|
Spacer(modifier = Modifier.width(12.dp))
|
||||||
Column(verticalArrangement = Arrangement.Center) {
|
Column(verticalArrangement = Arrangement.Center) {
|
||||||
Text(
|
Text(
|
||||||
text = type.getDisplayName(context),
|
text = type.getDisplayName(context),
|
||||||
style = MaterialTheme.typography.titleSmall,
|
style = MaterialTheme.typography.titleSmall,
|
||||||
maxLines = 1,
|
maxLines = 1
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Show trend only for numeric types with a difference
|
||||||
if (difference != null && trend != Trend.NOT_APPLICABLE) {
|
if (difference != null && trend != Trend.NOT_APPLICABLE) {
|
||||||
Spacer(modifier = Modifier.height(1.dp))
|
Spacer(modifier = Modifier.height(1.dp))
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
val trendIconVector = when (trend) {
|
val trendIconVector = when (trend) {
|
||||||
Trend.UP -> Icons.Filled.ArrowUpward
|
Trend.UP -> Icons.Filled.ArrowUpward
|
||||||
Trend.DOWN -> Icons.Filled.ArrowDownward
|
Trend.DOWN -> Icons.Filled.ArrowDownward
|
||||||
Trend.NONE -> null
|
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) {
|
if (trendIconVector != null) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = trendIconVector,
|
imageVector = trendIconVector,
|
||||||
contentDescription = trend.name,
|
contentDescription = trend.name,
|
||||||
tint = subtleGrayColor,
|
tint = subtle,
|
||||||
modifier = Modifier.size(12.dp)
|
modifier = Modifier.size(12.dp)
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.width(3.dp))
|
Spacer(modifier = Modifier.width(3.dp))
|
||||||
@@ -861,23 +961,218 @@ fun MeasurementValueRow(valueWithTrend: ValueWithDifference) {
|
|||||||
text = (if (difference > 0 && trend != Trend.NONE) "+" else "") +
|
text = (if (difference > 0 && trend != Trend.NONE) "+" else "") +
|
||||||
when (type.inputType) {
|
when (type.inputType) {
|
||||||
InputFieldType.FLOAT -> "%.1f".format(Locale.getDefault(), difference)
|
InputFieldType.FLOAT -> "%.1f".format(Locale.getDefault(), difference)
|
||||||
InputFieldType.INT -> difference.toInt().toString()
|
InputFieldType.INT -> difference.toInt().toString()
|
||||||
else -> ""
|
else -> ""
|
||||||
} + " ${type.unit.displayName}",
|
} + " $unitName",
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = subtleGrayColor
|
color = subtle
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
} else if (type.inputType == InputFieldType.FLOAT || type.inputType == InputFieldType.INT) {
|
} 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))
|
Spacer(modifier = Modifier.height((MaterialTheme.typography.bodySmall.fontSize.value + 2).dp))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Text(
|
|
||||||
text = "$displayValue ${type.unit.displayName}",
|
// Right side: value + evaluation symbol
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
Row(
|
||||||
textAlign = TextAlign.End,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
modifier = Modifier.padding(start = 8.dp)
|
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 ->
|
items(users) { user ->
|
||||||
// Calculate age. This will be recalculated if user.birthDate changes.
|
// Calculate age. This will be recalculated if user.birthDate changes.
|
||||||
val age = remember(user.birthDate) {
|
val age = remember(user.birthDate) {
|
||||||
CalculationUtil.dateToAge(user.birthDate)
|
CalculationUtil.ageOn(System.currentTimeMillis(), user.birthDate)
|
||||||
}
|
}
|
||||||
|
|
||||||
ListItem(
|
ListItem(
|
||||||
|
@@ -434,6 +434,10 @@
|
|||||||
<string name="delete_db_successful">Gesamte Datenbank wurde gelöscht.</string>
|
<string name="delete_db_successful">Gesamte Datenbank wurde gelöscht.</string>
|
||||||
<string name="delete_db_error">Fehler beim Löschen der gesamten Datenbank.</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 -->
|
<!-- 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_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>
|
<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_successful">Entire database has been deleted.</string>
|
||||||
<string name="delete_db_error">Error deleting entire database.</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 -->
|
<!-- BluetoothViewModel Snackbar Messages -->
|
||||||
<string name="bt_snackbar_permissions_required_to_scan">Bluetooth permissions are required to scan for devices.</string>
|
<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>
|
<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"
|
composeCharts = "2.1.3"
|
||||||
composeReorderable = "2.5.1"
|
composeReorderable = "2.5.1"
|
||||||
compose-material = "1.7.8"
|
compose-material = "1.7.8"
|
||||||
|
constraintlayout-compose = "1.1.1"
|
||||||
kotlinCsv = "1.10.0"
|
kotlinCsv = "1.10.0"
|
||||||
blessedKotlin = "3.0.9"
|
blessedKotlin = "3.0.9"
|
||||||
blessedJava = "2.5.2"
|
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-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
|
||||||
androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigation" }
|
androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigation" }
|
||||||
androidx-material3 = { group = "androidx.compose.material3", name = "material3" }
|
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-runtime = { module = "androidx.room:room-runtime", version.ref = "room" }
|
||||||
androidx-room-ktx = { module = "androidx.room:room-ktx", 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" }
|
androidx-room-compiler = { module = "androidx.room:room-compiler", version.ref = "room" }
|
||||||
|
Reference in New Issue
Block a user