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 minSdk = 31
targetSdk = 35 targetSdk = 35
versionCode = ciRunNumber ?: 1 versionCode = ciRunNumber ?: 1
versionName = "0.3.1" + (ciRunNumber?.let { "+build.$it" } ?: "-dev") versionName = "0.3.2" + (ciRunNumber?.let { "+build.$it" } ?: "-dev")
} }
signingConfigs { 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.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue 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.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight 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) { if (stats.heaviestLift != null || stats.newPrCount > 0) {
item { item {
Card(modifier = Modifier.fillMaxWidth()) { 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? { private fun delta(current: Double, previous: Double?): String? {
previous ?: return null previous ?: return null
if (previous == 0.0) return null if (previous == 0.0) return null