Statistik: kroppssammansättnings-graf med %/kg-växel + omräknad vikt
All checks were successful
release / build-release (push) Successful in 5m24s
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:
@@ -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 {
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user