Helnamn i hälsningen, flera egna stänger, kroppsvikt med historik och graf
All checks were successful
release / build-release (push) Successful in 5m16s

- Hälsningen använder profilens displayName (cachas lokalt)
- Skivkalkylatorn: obegränsat antal egna stänger med exakt vikt (13,7 kg
  funkar) — läggs till/tas bort i inställningarna, visas som chips
- Profil: uppdatera kroppsvikten direkt i appen (decimalinmatning);
  vikthistorik med uuid-baserad rättning och borttagning per mätning
  (addBodyMeasurement med fallback till updateBodyWeight mot äldre API)
- Statistik: kroppsviktsgraf med råpunkter, glidande medelvärde (mjuk
  linje) och streckad linjär trendlinje
- API:t har redan fälten muskel/fett/vatten-% för framtida kroppsanalys

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Kf4MAbZ3c3B4Xy5XfuGsCP
This commit is contained in:
2026-07-25 17:51:38 +02:00
parent d577a92eec
commit e2ffb4f011
11 changed files with 502 additions and 40 deletions

View File

@@ -160,6 +160,67 @@ class GymRepository(
}
suspend fun bodyWeightKg(): Double? = store.bodyWeightKg()
suspend fun displayName(): String? = store.displayName()
/* ---------- Kroppsmätningar ---------- */
suspend fun fetchBodyMeasurements(): List<BodyMeasurement> {
val data = client.execute(
"query{bodyMeasurements{id date weightKg musclePercent fatPercent waterPercent}}",
token = auth.bearerToken(),
)
return data["bodyMeasurements"]!!.jsonArray.map { m ->
val o = m.jsonObject
BodyMeasurement(
id = o["id"]!!.jsonPrimitive.content,
date = o["date"]?.jsonPrimitive?.contentOrNull() ?: "",
weightKg = o["weightKg"]?.jsonPrimitive?.doubleOrNull ?: 0.0,
musclePercent = o["musclePercent"]?.jsonPrimitive?.doubleOrNull,
fatPercent = o["fatPercent"]?.jsonPrimitive?.doubleOrNull,
waterPercent = o["waterPercent"]?.jsonPrimitive?.doubleOrNull,
)
}
}
/** Ny mätning. Faller tillbaka på gamla updateBodyWeight mot äldre API. */
suspend fun addBodyMeasurement(weightKg: Double) {
try {
client.execute(
"mutation(\$input: AddBodyMeasurementInput!){addBodyMeasurement(input:\$input){id}}",
buildJsonObject { put("input", buildJsonObject { put("weightKg", weightKg) }) },
auth.bearerToken(),
)
} catch (e: GraphQlException) {
client.execute(
"mutation(\$w: Float){updateBodyWeight(bodyWeightKg:\$w){bodyWeightKg}}",
buildJsonObject { put("w", weightKg) },
auth.bearerToken(),
)
}
store.saveBodyWeight(weightKg)
}
suspend fun updateBodyMeasurement(id: String, weightKg: Double, dateIso: String?) {
client.execute(
"mutation(\$input: UpdateBodyMeasurementInput!){updateBodyMeasurement(input:\$input){id}}",
buildJsonObject {
put("input", buildJsonObject {
put("id", id)
put("weightKg", weightKg)
dateIso?.let { put("date", it) }
})
},
auth.bearerToken(),
)
}
suspend fun deleteBodyMeasurement(id: String) {
client.execute(
"mutation(\$id: UUID!){deleteBodyMeasurement(id:\$id)}",
buildJsonObject { put("id", id) },
auth.bearerToken(),
)
}
/** Cachade personbästa som (exerciseTypeId, reps) → bästa vikt. */
suspend fun personalBests(): Map<Pair<Int, Int>, Double> =
@@ -239,10 +300,10 @@ class GymRepository(
}
runCatching {
val prof = client.execute("query{myProfile{bodyWeightKg}}", token = token)
store.saveBodyWeight(
prof["myProfile"]?.jsonObject?.get("bodyWeightKg")?.jsonPrimitive?.doubleOrNull
)
val prof = client.execute("query{myProfile{bodyWeightKg displayName}}", token = token)
val p = prof["myProfile"]?.jsonObject
store.saveBodyWeight(p?.get("bodyWeightKg")?.jsonPrimitive?.doubleOrNull)
store.saveDisplayName(p?.get("displayName")?.jsonPrimitive?.contentOrNull())
}
val favIds: Set<Int> = runCatching {
@@ -733,6 +794,15 @@ data class ExerciseStats(
val metValue: Double?,
)
data class BodyMeasurement(
val id: String,
val date: String,
val weightKg: Double,
val musclePercent: Double?,
val fatPercent: Double?,
val waterPercent: Double?,
)
data class TrendBucket(val label: String, val volumeKg: Double, val sessionCount: Int)
data class PbRecord(val reps: Int, val weight: Double, val date: String)
data class PbEntry(val exerciseName: String, val records: List<PbRecord>)

View File

@@ -21,8 +21,8 @@ data class AppSettings(
val weightStep: Double = 2.5,
/** Spara inloggningen krypterat och logga in tyst igen vid behov */
val autoRelogin: Boolean = true,
/** Egen stångvikt i skivkalkylatorn (utöver snabbvalen 20/0 kg) */
val customBarWeight: Double = 15.0,
/** Egna stångvikter i skivkalkylatorn (utöver snabbvalen 20/0 kg) */
val customBarWeights: List<Double> = listOf(15.0),
)
class SettingsStore(private val context: Context) {
@@ -38,12 +38,29 @@ class SettingsStore(private val context: Context) {
defaultRestSeconds = prefs[KEY_REST_SECONDS] ?: 90,
weightStep = prefs[KEY_WEIGHT_STEP]?.toDoubleOrNull() ?: 2.5,
autoRelogin = prefs[KEY_AUTO_RELOGIN]?.toBooleanStrictOrNull() ?: true,
customBarWeight = prefs[KEY_CUSTOM_BAR]?.toDoubleOrNull() ?: 15.0,
customBarWeights = prefs[KEY_CUSTOM_BARS]?.split(",")
?.mapNotNull { it.toDoubleOrNull() }
// migrera från gamla enkel-värdet
?: prefs[KEY_CUSTOM_BAR]?.toDoubleOrNull()?.let { listOf(it) }
?: listOf(15.0),
)
}
suspend fun setCustomBarWeight(value: Double) {
context.settingsDataStore.edit { it[KEY_CUSTOM_BAR] = value.coerceIn(0.0, 60.0).toString() }
suspend fun addCustomBarWeight(value: Double) {
val v = value.coerceIn(0.1, 100.0)
context.settingsDataStore.edit { prefs ->
val current = prefs[KEY_CUSTOM_BARS]?.split(",")
?.mapNotNull { it.toDoubleOrNull() } ?: listOf(15.0)
prefs[KEY_CUSTOM_BARS] = (current + v).distinct().sorted().joinToString(",")
}
}
suspend fun removeCustomBarWeight(value: Double) {
context.settingsDataStore.edit { prefs ->
val current = prefs[KEY_CUSTOM_BARS]?.split(",")
?.mapNotNull { it.toDoubleOrNull() } ?: listOf(15.0)
prefs[KEY_CUSTOM_BARS] = current.filter { it != value }.joinToString(",")
}
}
suspend fun setWeightStep(value: Double) {
@@ -73,5 +90,6 @@ class SettingsStore(private val context: Context) {
private val KEY_WEIGHT_STEP = stringPreferencesKey("weight_step")
private val KEY_AUTO_RELOGIN = stringPreferencesKey("auto_relogin")
private val KEY_CUSTOM_BAR = stringPreferencesKey("custom_bar_weight")
private val KEY_CUSTOM_BARS = stringPreferencesKey("custom_bar_weights")
}
}

View File

@@ -74,6 +74,15 @@ class TokenStore(private val context: Context) {
suspend fun bodyWeightKg(): Double? =
context.authDataStore.data.first()[KEY_BODY_WEIGHT]
suspend fun saveDisplayName(name: String?) {
context.authDataStore.edit {
if (name.isNullOrBlank()) it.remove(KEY_DISPLAY_NAME) else it[KEY_DISPLAY_NAME] = name
}
}
suspend fun displayName(): String? =
context.authDataStore.data.first()[KEY_DISPLAY_NAME]
/** Rensar sessionen men behåller vald API-url. */
suspend fun clearSession() {
context.authDataStore.edit {
@@ -95,5 +104,6 @@ class TokenStore(private val context: Context) {
private val KEY_EXPIRATION = longPreferencesKey("expiration_epoch_ms")
private val KEY_API_URL = stringPreferencesKey("api_url")
private val KEY_BODY_WEIGHT = doublePreferencesKey("body_weight_kg")
private val KEY_DISPLAY_NAME = stringPreferencesKey("display_name")
}
}

View File

@@ -42,6 +42,7 @@ fun HomeScreen(
) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val updateState by viewModel.updateState.collectAsStateWithLifecycle()
val displayName by viewModel.displayName.collectAsStateWithLifecycle()
Column(
modifier = Modifier
@@ -52,7 +53,10 @@ fun HomeScreen(
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Column(modifier = Modifier.weight(1f)) {
Text("Tjena ${uiState.username}", style = MaterialTheme.typography.headlineSmall)
Text(
"Tjena ${displayName ?: uiState.username}",
style = MaterialTheme.typography.headlineSmall,
)
Text(
"Redo för ett pass?",
style = MaterialTheme.typography.bodyMedium,

View File

@@ -38,8 +38,12 @@ class HomeViewModel(
val updateState = kotlinx.coroutines.flow.MutableStateFlow<UpdateState?>(null)
/** Helnamn från profilen — trevligare hälsning än användarnamnet. */
val displayName = kotlinx.coroutines.flow.MutableStateFlow<String?>(null)
init {
viewModelScope.launch {
displayName.value = repo.displayName()
updateChecker.check()?.let { updateState.value = UpdateState.Available(it) }
}
}

View File

@@ -8,19 +8,28 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Logout
import androidx.compose.material.icons.filled.MonitorWeight
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
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.input.KeyboardType
import androidx.compose.ui.unit.dp
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
@@ -30,21 +39,64 @@ import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory
import eu.brassepc.fitnessdroid.data.AuthRepository
import eu.brassepc.fitnessdroid.data.BodyMeasurement
import eu.brassepc.fitnessdroid.data.GymApi
import eu.brassepc.fitnessdroid.data.GymRepository
import eu.brassepc.fitnessdroid.data.Profile
import eu.brassepc.fitnessdroid.ui.appContainer
import eu.brassepc.fitnessdroid.ui.common.compact
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.launch
class ProfileViewModel(
private val gymApi: GymApi,
private val auth: AuthRepository,
private val repo: GymRepository,
) : ViewModel() {
val profile = MutableStateFlow<Profile?>(null)
val measurements = MutableStateFlow<List<BodyMeasurement>>(emptyList())
val message = MutableStateFlow<String?>(null)
init {
init { load() }
fun load() {
viewModelScope.launch {
runCatching { profile.value = gymApi.myProfile() }
runCatching { measurements.value = repo.fetchBodyMeasurements().reversed() }
}
}
fun addWeight(weightKg: Double) {
viewModelScope.launch {
try {
repo.addBodyMeasurement(weightKg)
message.value = "Vikten sparad"
load()
} catch (e: Exception) {
message.value = "Kunde inte spara — offline?"
}
}
}
fun updateMeasurement(id: String, weightKg: Double) {
viewModelScope.launch {
try {
repo.updateBodyMeasurement(id, weightKg, null)
load()
} catch (e: Exception) {
message.value = "Kunde inte uppdatera — offline?"
}
}
}
fun deleteMeasurement(id: String) {
viewModelScope.launch {
try {
repo.deleteBodyMeasurement(id)
load()
} catch (e: Exception) {
message.value = "Kunde inte ta bort — offline?"
}
}
}
@@ -56,7 +108,7 @@ class ProfileViewModel(
val Factory: ViewModelProvider.Factory = viewModelFactory {
initializer {
val c = appContainer()
ProfileViewModel(c.gymApi, c.authRepository)
ProfileViewModel(c.gymApi, c.authRepository, c.gymRepository)
}
}
}
@@ -68,6 +120,38 @@ fun ProfileScreen(
viewModel: ProfileViewModel = viewModel(factory = ProfileViewModel.Factory),
) {
val profile by viewModel.profile.collectAsStateWithLifecycle()
val measurements by viewModel.measurements.collectAsStateWithLifecycle()
val message by viewModel.message.collectAsStateWithLifecycle()
var showAddWeight by remember { mutableStateOf(false) }
var editTarget by remember { mutableStateOf<BodyMeasurement?>(null) }
if (showAddWeight) {
WeightDialog(
title = "Uppdatera kroppsvikt",
initial = profile?.bodyWeightKg,
onSave = { weight ->
viewModel.addWeight(weight)
showAddWeight = false
},
onDismiss = { showAddWeight = false },
)
}
editTarget?.let { m ->
WeightDialog(
title = "Rätta mätning ${m.date.take(10)}",
initial = m.weightKg,
onSave = { weight ->
viewModel.updateMeasurement(m.id, weight)
editTarget = null
},
onDelete = {
viewModel.deleteMeasurement(m.id)
editTarget = null
},
onDismiss = { editTarget = null },
)
}
Column(
modifier = Modifier
@@ -86,7 +170,62 @@ fun ProfileScreen(
ProfileRow("Användarnamn", profile?.username ?: "")
profile?.displayName?.let { ProfileRow("Namn", it) }
profile?.email?.let { ProfileRow("E-post", it) }
profile?.bodyWeightKg?.let { ProfileRow("Kroppsvikt", "$it kg") }
}
}
Card(modifier = Modifier.fillMaxWidth()) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
Icons.Default.MonitorWeight,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
)
Column(modifier = Modifier.weight(1f).padding(start = 10.dp)) {
Text("Kroppsvikt", style = MaterialTheme.typography.titleSmall)
Text(
profile?.bodyWeightKg?.let { "${it.compact()} kg" } ?: "Inte satt",
style = MaterialTheme.typography.headlineSmall,
)
}
Button(onClick = { showAddWeight = true }) { Text("Uppdatera") }
}
message?.let {
Text(
it,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
if (measurements.isNotEmpty()) {
Text(
"Historik (tryck för att rätta)",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
measurements.take(8).forEach { m ->
Row(
modifier = Modifier
.fillMaxWidth()
.clickable { editTarget = m }
.padding(vertical = 4.dp),
) {
Text(
m.date.take(10),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(1f),
)
Text(
"${m.weightKg.compact()} kg",
style = MaterialTheme.typography.bodySmall,
)
}
}
}
}
}
@@ -103,7 +242,7 @@ fun ProfileScreen(
Column(modifier = Modifier.weight(1f).padding(start = 12.dp)) {
Text("Inställningar", style = MaterialTheme.typography.titleSmall)
Text(
"Vilotimer, larm, standardvila",
"Vilotimer, viktsteg, stänger, inloggning",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
@@ -118,6 +257,51 @@ fun ProfileScreen(
}
}
@Composable
private fun WeightDialog(
title: String,
initial: Double?,
onSave: (Double) -> Unit,
onDismiss: () -> Unit,
onDelete: (() -> Unit)? = null,
) {
var text by remember {
mutableStateOf(initial?.let { if (it % 1.0 == 0.0) it.toInt().toString() else it.toString() } ?: "")
}
val parsed = text.trim().replace(',', '.').toDoubleOrNull()
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(title) },
text = {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedTextField(
value = text,
onValueChange = { text = it },
label = { Text("Vikt (kg)") },
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
modifier = Modifier.fillMaxWidth(),
)
onDelete?.let {
TextButton(onClick = it) {
Text("Ta bort mätningen", color = MaterialTheme.colorScheme.error)
}
}
}
},
confirmButton = {
Button(
onClick = { parsed?.let(onSave) },
enabled = parsed != null && parsed > 0,
) { Text("Spara") }
},
dismissButton = {
TextButton(onClick = onDismiss) { Text("Avbryt") }
},
)
}
@Composable
private fun ProfileRow(label: String, value: String) {
Row(modifier = Modifier.fillMaxWidth()) {

View File

@@ -57,15 +57,11 @@ private fun breakdown(perSideGrams: Int): Pair<List<Plate>, Int> {
@Composable
fun PlateCalculatorSheet(
weightKg: Double,
customBarWeight: Double,
customBarWeights: List<Double>,
onDismiss: () -> Unit,
) {
var barKg by remember { mutableStateOf(20.0) }
val barOptions = buildList {
add(20.0)
add(0.0)
if (customBarWeight != 20.0 && customBarWeight != 0.0) add(customBarWeight)
}
val barOptions = (listOf(20.0, 0.0) + customBarWeights).distinct()
val perSideGrams = (((weightKg - barKg) / 2.0) * 1000).toInt().coerceAtLeast(0)
val (plates, restGrams) = breakdown(perSideGrams)
@@ -80,8 +76,9 @@ fun PlateCalculatorSheet(
style = MaterialTheme.typography.titleLarge,
)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
barOptions.forEach { option ->
androidx.compose.foundation.lazy.LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
items(barOptions.size) { i ->
val option = barOptions[i]
FilterChip(
selected = barKg == option,
onClick = { barKg = option },
@@ -89,7 +86,7 @@ fun PlateCalculatorSheet(
Text(
when (option) {
0.0 -> "Utan stång"
customBarWeight -> "Egen ${option.compact()} kg"
20.0 -> "Stång 20 kg"
else -> "Stång ${option.compact()} kg"
}
)

View File

@@ -181,7 +181,7 @@ fun SessionScreen(
plateWeight?.let { weight ->
PlateCalculatorSheet(
weightKg = weight,
customBarWeight = uiState.settings.customBarWeight,
customBarWeights = uiState.settings.customBarWeights,
onDismiss = { plateWeight = null },
)
}

View File

@@ -11,6 +11,8 @@ import androidx.compose.foundation.selection.selectable
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Close
import androidx.compose.runtime.setValue
import androidx.compose.material3.Card
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilledTonalButton
@@ -65,9 +67,8 @@ class SettingsViewModel(
fun setWeightStep(value: Double) = viewModelScope.launch { store.setWeightStep(value) }
fun adjustCustomBar(delta: Double) = viewModelScope.launch {
store.setCustomBarWeight(settings.value.customBarWeight + delta)
}
fun addCustomBar(value: Double) = viewModelScope.launch { store.addCustomBarWeight(value) }
fun removeCustomBar(value: Double) = viewModelScope.launch { store.removeCustomBarWeight(value) }
fun setAutoRelogin(value: Boolean) = viewModelScope.launch {
store.setAutoRelogin(value)
@@ -181,27 +182,59 @@ fun SettingsScreen(
}
}
SettingsCard(title = "Skivkalkylatorn — egen stångvikt") {
SettingsCard(title = "Skivkalkylatorn — egna stänger") {
androidx.compose.foundation.lazy.LazyRow(
horizontalArrangement = Arrangement.spacedBy(8.dp),
contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 16.dp),
) {
items(settings.customBarWeights.size) { i ->
val bar = settings.customBarWeights[i]
androidx.compose.material3.InputChip(
selected = false,
onClick = { viewModel.removeCustomBar(bar) },
label = {
Text(
if (bar % 1.0 == 0.0) "${bar.toInt()} kg" else "$bar kg".replace('.', ',')
)
},
trailingIcon = {
androidx.compose.material3.Icon(
Icons.Default.Close,
contentDescription = "Ta bort",
)
},
)
}
}
var newBar by androidx.compose.runtime.remember {
androidx.compose.runtime.mutableStateOf("")
}
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp),
.padding(horizontal = 16.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
FilledTonalButton(onClick = { viewModel.adjustCustomBar(-2.5) }) { Text("2,5") }
Text(
"${if (settings.customBarWeight % 1.0 == 0.0) settings.customBarWeight.toInt().toString() else settings.customBarWeight} kg",
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold,
androidx.compose.material3.OutlinedTextField(
value = newBar,
onValueChange = { newBar = it },
label = { Text("Ny stångvikt (t.ex. 13,7)") },
singleLine = true,
keyboardOptions = androidx.compose.foundation.text.KeyboardOptions(
keyboardType = androidx.compose.ui.text.input.KeyboardType.Decimal,
),
modifier = Modifier.weight(1f),
textAlign = androidx.compose.ui.text.style.TextAlign.Center,
)
FilledTonalButton(onClick = { viewModel.adjustCustomBar(2.5) }) { Text("+2,5") }
FilledTonalButton(onClick = {
newBar.trim().replace(',', '.').toDoubleOrNull()?.let {
viewModel.addCustomBar(it)
newBar = ""
}
}) { Text("Lägg till") }
}
Text(
"Snabbvalen 20 kg och utan stång finns alltid i kalkylatorn" +
"det här är det tredje alternativet (t.ex. lättare stång).",
"Snabbvalen 20 kg och utan stång finns alltid i kalkylatorn.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(start = 16.dp, end = 16.dp, bottom = 12.dp),

View File

@@ -57,8 +57,14 @@ sealed interface StatsUiState {
class StatsViewModel(private val repo: GymRepository) : ViewModel() {
val period = MutableStateFlow("month")
val uiState = MutableStateFlow<StatsUiState>(StatsUiState.Loading)
val bodyMeasurements = MutableStateFlow<List<eu.brassepc.fitnessdroid.data.BodyMeasurement>>(emptyList())
init { load() }
init {
load()
viewModelScope.launch {
runCatching { bodyMeasurements.value = repo.fetchBodyMeasurements() }
}
}
fun setPeriod(id: String) {
if (period.value == id) return
@@ -211,6 +217,55 @@ fun StatsScreen(viewModel: StatsViewModel = viewModel(factory = StatsViewModel.F
}
}
item {
val measurements by viewModel.bodyMeasurements.collectAsStateWithLifecycle()
if (measurements.size >= 2) {
Card(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.padding(14.dp)) {
Row {
Text(
"Kroppsvikt",
style = MaterialTheme.typography.titleSmall,
modifier = Modifier.weight(1f),
)
Text(
"${measurements.last().weightKg.compact()} kg nu",
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.primary,
)
}
WeightGraph(
measurements = measurements,
lineColor = MaterialTheme.colorScheme.primary,
trendColor = MaterialTheme.colorScheme.tertiary,
pointColor = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 10.dp),
)
Row(
modifier = Modifier.fillMaxWidth().padding(top = 6.dp),
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(
measurements.first().date.take(10),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
"— mjuk linje · - - trend",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
measurements.last().date.take(10),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
}
if (stats.heaviestLift != null || stats.newPrCount > 0) {
item {
Card(modifier = Modifier.fillMaxWidth()) {

View File

@@ -0,0 +1,87 @@
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.PathEffect
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.unit.dp
import eu.brassepc.fitnessdroid.data.BodyMeasurement
import java.time.OffsetDateTime
/**
* Kroppsvikt över tid: råpunkter, glidande medelvärde (mjuk linje) och
* linjär trendlinje (streckad).
*/
@Composable
fun WeightGraph(
measurements: List<BodyMeasurement>,
lineColor: Color,
trendColor: Color,
pointColor: Color,
modifier: Modifier = Modifier,
) {
val points = measurements.mapNotNull { m ->
runCatching {
OffsetDateTime.parse(m.date).toInstant().toEpochMilli().toFloat() to m.weightKg.toFloat()
}.getOrNull()
}.sortedBy { it.first }
if (points.size < 2) return
val minX = points.first().first
val maxX = points.last().first
val minW = points.minOf { it.second }
val maxW = points.maxOf { it.second }
val padW = ((maxW - minW).takeIf { it > 0f } ?: 1f) * 0.15f
val loW = minW - padW
val hiW = maxW + padW
// Glidande medelvärde (fönster 5)
val smooth = points.mapIndexed { i, _ ->
val from = (i - 2).coerceAtLeast(0)
val to = (i + 2).coerceAtMost(points.lastIndex)
val slice = points.subList(from, to + 1)
points[i].first to slice.map { it.second }.average().toFloat()
}
// Linjär regression för trendlinjen
val n = points.size.toFloat()
val meanX = points.map { it.first }.average().toFloat()
val meanY = points.map { it.second }.average().toFloat()
val denominator = points.sumOf { ((it.first - meanX) * (it.first - meanX)).toDouble() }
val slope = if (denominator == 0.0) 0f else {
(points.sumOf { ((it.first - meanX) * (it.second - meanY)).toDouble() } / denominator).toFloat()
}
val intercept = meanY - slope * meanX
Canvas(modifier = modifier.fillMaxWidth().height(160.dp)) {
fun x(v: Float) = (v - minX) / (maxX - minX).coerceAtLeast(1f) * size.width
fun y(w: Float) = size.height - (w - loW) / (hiW - loW) * size.height
// Trendlinje (streckad)
drawLine(
color = trendColor,
start = Offset(0f, y(slope * minX + intercept)),
end = Offset(size.width, y(slope * maxX + intercept)),
strokeWidth = 3f,
pathEffect = PathEffect.dashPathEffect(floatArrayOf(14f, 10f)),
)
// Mjuk linje (glidande medelvärde)
val path = Path()
smooth.forEachIndexed { i, (px, pw) ->
if (i == 0) path.moveTo(x(px), y(pw)) else path.lineTo(x(px), y(pw))
}
drawPath(path, color = lineColor, style = Stroke(width = 5f))
// Råpunkter
points.forEach { (px, pw) ->
drawCircle(color = pointColor, radius = 6f, center = Offset(x(px), y(pw)))
}
}
}