Statistik: kroppssammansättnings-graf med %/kg-växel + omräknad vikt
All checks were successful
release / build-release (push) Successful in 5m24s

Ny sammansättningskort i statistiken: senaste muskel/fett/vatten i % med
omräkning till faktisk vikt (vikt × % / 100), plus flerlinjes-graf över
tid som växlar mellan procent och kg. Version 0.3.2.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Kf4MAbZ3c3B4Xy5XfuGsCP
This commit is contained in:
2026-07-26 00:10:04 +02:00
parent 7c21428a28
commit b33a849f23
3 changed files with 162 additions and 1 deletions

View File

@@ -29,7 +29,7 @@ android {
minSdk = 31
targetSdk = 35
versionCode = ciRunNumber ?: 1
versionName = "0.3.1" + (ciRunNumber?.let { "+build.$it" } ?: "-dev")
versionName = "0.3.2" + (ciRunNumber?.let { "+build.$it" } ?: "-dev")
}
signingConfigs {

View File

@@ -0,0 +1,57 @@
package eu.brassepc.fitnessdroid.ui.stats
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.unit.dp
import eu.brassepc.fitnessdroid.data.BodyMeasurement
import java.time.OffsetDateTime
data class CompositionSeries(val label: String, val color: Color, val values: List<Double?>)
/**
* Flerlinjes-graf för kroppssammansättning över tid. Varje serie (muskel/
* fett/vatten) ritas som egen linje; null-värden hoppas över (spanGaps).
* Delar tidsordningen med [WeightGraph].
*/
@Composable
fun CompositionGraph(
measurements: List<BodyMeasurement>,
series: List<CompositionSeries>,
modifier: Modifier = Modifier,
) {
val order = measurements.mapIndexedNotNull { i, m ->
runCatching { OffsetDateTime.parse(m.date).toInstant().toEpochMilli() }.getOrNull()?.let { i to it }
}.sortedBy { it.second }.map { it.first }
if (order.size < 2) return
val allValues = series.flatMap { it.values }.filterNotNull()
if (allValues.isEmpty()) return
val lo = allValues.min() * 0.9
val hi = allValues.max() * 1.1
val span = (hi - lo).takeIf { it > 0 } ?: 1.0
Canvas(modifier = modifier.fillMaxWidth().height(160.dp)) {
fun x(idx: Int) = idx / (order.size - 1).toFloat() * size.width
fun y(v: Double) = (size.height - ((v - lo) / span).toFloat() * size.height)
series.forEach { s ->
val path = Path()
var started = false
order.forEachIndexed { plotIdx, measurementIdx ->
val v = s.values.getOrNull(measurementIdx) ?: return@forEachIndexed
val px = x(plotIdx)
val py = y(v)
if (!started) { path.moveTo(px, py); started = true } else path.lineTo(px, py)
drawCircle(s.color, radius = 5f, center = Offset(px, py))
}
if (started) drawPath(path, color = s.color, style = Stroke(width = 4f))
}
}
}

View File

@@ -20,6 +20,9 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
@@ -276,6 +279,16 @@ fun StatsScreen(viewModel: StatsViewModel = viewModel(factory = StatsViewModel.F
}
}
item {
val measurements by viewModel.bodyMeasurements.collectAsStateWithLifecycle()
val hasComposition = measurements.any {
it.musclePercent != null || it.fatPercent != null || it.waterPercent != null
}
if (measurements.size >= 2 && hasComposition) {
CompositionCard(measurements)
}
}
if (stats.heaviestLift != null || stats.newPrCount > 0) {
item {
Card(modifier = Modifier.fillMaxWidth()) {
@@ -317,6 +330,97 @@ fun StatsScreen(viewModel: StatsViewModel = viewModel(factory = StatsViewModel.F
}
}
private data class CompPart(val label: String, val value: (eu.brassepc.fitnessdroid.data.BodyMeasurement) -> Double?)
@Composable
private fun CompositionCard(measurements: List<eu.brassepc.fitnessdroid.data.BodyMeasurement>) {
var showKg by remember { mutableStateOf(false) }
val latest = measurements.last()
val muscle = MaterialTheme.colorScheme.primary
val fat = MaterialTheme.colorScheme.tertiary
val water = MaterialTheme.colorScheme.secondary
val parts = listOf(
Triple("Muskler", muscle, CompPart("Muskler") { it.musclePercent }),
Triple("Fett", fat, CompPart("Fett") { it.fatPercent }),
Triple("Vatten", water, CompPart("Vatten") { it.waterPercent }),
).filter { (_, _, p) -> measurements.any { p.value(it) != null } }
fun kgOf(m: eu.brassepc.fitnessdroid.data.BodyMeasurement, pct: Double?): Double? =
if (pct == null) null else m.weightKg * pct / 100.0
Card(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.padding(14.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
"Sammansättning",
style = MaterialTheme.typography.titleSmall,
modifier = Modifier.weight(1f),
)
// %/kg-växel
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
FilterChip(
selected = !showKg,
onClick = { showKg = false },
label = { Text("%") },
)
FilterChip(
selected = showKg,
onClick = { showKg = true },
label = { Text("kg") },
)
}
}
// Senaste värden: % + omräknat till kg
Row(
modifier = Modifier.fillMaxWidth().padding(top = 10.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
parts.forEach { (label, color, part) ->
val pct = part.value(latest)
val kg = kgOf(latest, pct)
Column(modifier = Modifier.weight(1f)) {
Text(label, style = MaterialTheme.typography.labelSmall, color = color)
Text(
pct?.let { "${it.compact()}%" } ?: "",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
)
Text(
kg?.let { "${it.compact()} kg" } ?: "",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
CompositionGraph(
measurements = measurements,
series = parts.map { (label, color, part) ->
CompositionSeries(
label = label,
color = color,
values = measurements.map { m ->
val pct = part.value(m)
if (showKg) kgOf(m, pct) else pct
},
)
},
modifier = Modifier.padding(top = 12.dp),
)
Text(
if (showKg) "Faktisk vikt per del (kg)" else "Andel av kroppsvikten (%)",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 6.dp),
)
}
}
}
private fun delta(current: Double, previous: Double?): String? {
previous ?: return null
if (previous == 0.0) return null