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
|
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 {
|
||||||
|
|||||||
@@ -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.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
|
||||||
|
|||||||
Reference in New Issue
Block a user