From b33a849f23435ded4e28cc417b9fa5acd247e73e Mon Sep 17 00:00:00 2001 From: claude Date: Sun, 26 Jul 2026 00:10:04 +0200 Subject: [PATCH] =?UTF-8?q?Statistik:=20kroppssammans=C3=A4ttnings-graf=20?= =?UTF-8?q?med=20%/kg-v=C3=A4xel=20+=20omr=C3=A4knad=20vikt?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01Kf4MAbZ3c3B4Xy5XfuGsCP --- app/build.gradle.kts | 2 +- .../fitnessdroid/ui/stats/CompositionGraph.kt | 57 ++++++++++ .../fitnessdroid/ui/stats/StatsScreen.kt | 104 ++++++++++++++++++ 3 files changed, 162 insertions(+), 1 deletion(-) create mode 100644 app/src/main/java/eu/brassepc/fitnessdroid/ui/stats/CompositionGraph.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index b61b869..9ce61a1 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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 { diff --git a/app/src/main/java/eu/brassepc/fitnessdroid/ui/stats/CompositionGraph.kt b/app/src/main/java/eu/brassepc/fitnessdroid/ui/stats/CompositionGraph.kt new file mode 100644 index 0000000..fb23952 --- /dev/null +++ b/app/src/main/java/eu/brassepc/fitnessdroid/ui/stats/CompositionGraph.kt @@ -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) + +/** + * 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, + series: List, + 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)) + } + } +} diff --git a/app/src/main/java/eu/brassepc/fitnessdroid/ui/stats/StatsScreen.kt b/app/src/main/java/eu/brassepc/fitnessdroid/ui/stats/StatsScreen.kt index d4cfd5b..fc48556 100644 --- a/app/src/main/java/eu/brassepc/fitnessdroid/ui/stats/StatsScreen.kt +++ b/app/src/main/java/eu/brassepc/fitnessdroid/ui/stats/StatsScreen.kt @@ -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) { + 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