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 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. */ /** Cachade personbästa som (exerciseTypeId, reps) → bästa vikt. */
suspend fun personalBests(): Map<Pair<Int, Int>, Double> = suspend fun personalBests(): Map<Pair<Int, Int>, Double> =
@@ -239,10 +300,10 @@ class GymRepository(
} }
runCatching { runCatching {
val prof = client.execute("query{myProfile{bodyWeightKg}}", token = token) val prof = client.execute("query{myProfile{bodyWeightKg displayName}}", token = token)
store.saveBodyWeight( val p = prof["myProfile"]?.jsonObject
prof["myProfile"]?.jsonObject?.get("bodyWeightKg")?.jsonPrimitive?.doubleOrNull store.saveBodyWeight(p?.get("bodyWeightKg")?.jsonPrimitive?.doubleOrNull)
) store.saveDisplayName(p?.get("displayName")?.jsonPrimitive?.contentOrNull())
} }
val favIds: Set<Int> = runCatching { val favIds: Set<Int> = runCatching {
@@ -733,6 +794,15 @@ data class ExerciseStats(
val metValue: Double?, 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 TrendBucket(val label: String, val volumeKg: Double, val sessionCount: Int)
data class PbRecord(val reps: Int, val weight: Double, val date: String) data class PbRecord(val reps: Int, val weight: Double, val date: String)
data class PbEntry(val exerciseName: String, val records: List<PbRecord>) data class PbEntry(val exerciseName: String, val records: List<PbRecord>)

View File

@@ -21,8 +21,8 @@ data class AppSettings(
val weightStep: Double = 2.5, val weightStep: Double = 2.5,
/** Spara inloggningen krypterat och logga in tyst igen vid behov */ /** Spara inloggningen krypterat och logga in tyst igen vid behov */
val autoRelogin: Boolean = true, val autoRelogin: Boolean = true,
/** Egen stångvikt i skivkalkylatorn (utöver snabbvalen 20/0 kg) */ /** Egna stångvikter i skivkalkylatorn (utöver snabbvalen 20/0 kg) */
val customBarWeight: Double = 15.0, val customBarWeights: List<Double> = listOf(15.0),
) )
class SettingsStore(private val context: Context) { class SettingsStore(private val context: Context) {
@@ -38,12 +38,29 @@ class SettingsStore(private val context: Context) {
defaultRestSeconds = prefs[KEY_REST_SECONDS] ?: 90, defaultRestSeconds = prefs[KEY_REST_SECONDS] ?: 90,
weightStep = prefs[KEY_WEIGHT_STEP]?.toDoubleOrNull() ?: 2.5, weightStep = prefs[KEY_WEIGHT_STEP]?.toDoubleOrNull() ?: 2.5,
autoRelogin = prefs[KEY_AUTO_RELOGIN]?.toBooleanStrictOrNull() ?: true, 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) { suspend fun addCustomBarWeight(value: Double) {
context.settingsDataStore.edit { it[KEY_CUSTOM_BAR] = value.coerceIn(0.0, 60.0).toString() } 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) { 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_WEIGHT_STEP = stringPreferencesKey("weight_step")
private val KEY_AUTO_RELOGIN = stringPreferencesKey("auto_relogin") private val KEY_AUTO_RELOGIN = stringPreferencesKey("auto_relogin")
private val KEY_CUSTOM_BAR = stringPreferencesKey("custom_bar_weight") 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? = suspend fun bodyWeightKg(): Double? =
context.authDataStore.data.first()[KEY_BODY_WEIGHT] 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. */ /** Rensar sessionen men behåller vald API-url. */
suspend fun clearSession() { suspend fun clearSession() {
context.authDataStore.edit { context.authDataStore.edit {
@@ -95,5 +104,6 @@ class TokenStore(private val context: Context) {
private val KEY_EXPIRATION = longPreferencesKey("expiration_epoch_ms") private val KEY_EXPIRATION = longPreferencesKey("expiration_epoch_ms")
private val KEY_API_URL = stringPreferencesKey("api_url") private val KEY_API_URL = stringPreferencesKey("api_url")
private val KEY_BODY_WEIGHT = doublePreferencesKey("body_weight_kg") 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 uiState by viewModel.uiState.collectAsStateWithLifecycle()
val updateState by viewModel.updateState.collectAsStateWithLifecycle() val updateState by viewModel.updateState.collectAsStateWithLifecycle()
val displayName by viewModel.displayName.collectAsStateWithLifecycle()
Column( Column(
modifier = Modifier modifier = Modifier
@@ -52,7 +53,10 @@ fun HomeScreen(
) { ) {
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
Column(modifier = Modifier.weight(1f)) { Column(modifier = Modifier.weight(1f)) {
Text("Tjena ${uiState.username}", style = MaterialTheme.typography.headlineSmall) Text(
"Tjena ${displayName ?: uiState.username}",
style = MaterialTheme.typography.headlineSmall,
)
Text( Text(
"Redo för ett pass?", "Redo för ett pass?",
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,

View File

@@ -38,8 +38,12 @@ class HomeViewModel(
val updateState = kotlinx.coroutines.flow.MutableStateFlow<UpdateState?>(null) 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 { init {
viewModelScope.launch { viewModelScope.launch {
displayName.value = repo.displayName()
updateChecker.check()?.let { updateState.value = UpdateState.Available(it) } 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.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Logout 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.material.icons.filled.Settings
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
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.input.KeyboardType
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.ViewModelProvider
@@ -30,21 +39,64 @@ import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.lifecycle.viewmodel.initializer import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory import androidx.lifecycle.viewmodel.viewModelFactory
import eu.brassepc.fitnessdroid.data.AuthRepository import eu.brassepc.fitnessdroid.data.AuthRepository
import eu.brassepc.fitnessdroid.data.BodyMeasurement
import eu.brassepc.fitnessdroid.data.GymApi import eu.brassepc.fitnessdroid.data.GymApi
import eu.brassepc.fitnessdroid.data.GymRepository
import eu.brassepc.fitnessdroid.data.Profile import eu.brassepc.fitnessdroid.data.Profile
import eu.brassepc.fitnessdroid.ui.appContainer import eu.brassepc.fitnessdroid.ui.appContainer
import eu.brassepc.fitnessdroid.ui.common.compact
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
class ProfileViewModel( class ProfileViewModel(
private val gymApi: GymApi, private val gymApi: GymApi,
private val auth: AuthRepository, private val auth: AuthRepository,
private val repo: GymRepository,
) : ViewModel() { ) : ViewModel() {
val profile = MutableStateFlow<Profile?>(null) val profile = MutableStateFlow<Profile?>(null)
val measurements = MutableStateFlow<List<BodyMeasurement>>(emptyList())
val message = MutableStateFlow<String?>(null)
init { init { load() }
fun load() {
viewModelScope.launch { viewModelScope.launch {
runCatching { profile.value = gymApi.myProfile() } 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 { val Factory: ViewModelProvider.Factory = viewModelFactory {
initializer { initializer {
val c = appContainer() 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), viewModel: ProfileViewModel = viewModel(factory = ProfileViewModel.Factory),
) { ) {
val profile by viewModel.profile.collectAsStateWithLifecycle() 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( Column(
modifier = Modifier modifier = Modifier
@@ -86,7 +170,62 @@ fun ProfileScreen(
ProfileRow("Användarnamn", profile?.username ?: "") ProfileRow("Användarnamn", profile?.username ?: "")
profile?.displayName?.let { ProfileRow("Namn", it) } profile?.displayName?.let { ProfileRow("Namn", it) }
profile?.email?.let { ProfileRow("E-post", 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)) { Column(modifier = Modifier.weight(1f).padding(start = 12.dp)) {
Text("Inställningar", style = MaterialTheme.typography.titleSmall) Text("Inställningar", style = MaterialTheme.typography.titleSmall)
Text( Text(
"Vilotimer, larm, standardvila", "Vilotimer, viktsteg, stänger, inloggning",
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant, 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 @Composable
private fun ProfileRow(label: String, value: String) { private fun ProfileRow(label: String, value: String) {
Row(modifier = Modifier.fillMaxWidth()) { Row(modifier = Modifier.fillMaxWidth()) {

View File

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

View File

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

View File

@@ -11,6 +11,8 @@ import androidx.compose.foundation.selection.selectable
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack 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.Card
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.FilledTonalButton
@@ -65,9 +67,8 @@ class SettingsViewModel(
fun setWeightStep(value: Double) = viewModelScope.launch { store.setWeightStep(value) } fun setWeightStep(value: Double) = viewModelScope.launch { store.setWeightStep(value) }
fun adjustCustomBar(delta: Double) = viewModelScope.launch { fun addCustomBar(value: Double) = viewModelScope.launch { store.addCustomBarWeight(value) }
store.setCustomBarWeight(settings.value.customBarWeight + delta) fun removeCustomBar(value: Double) = viewModelScope.launch { store.removeCustomBarWeight(value) }
}
fun setAutoRelogin(value: Boolean) = viewModelScope.launch { fun setAutoRelogin(value: Boolean) = viewModelScope.launch {
store.setAutoRelogin(value) 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( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp), .padding(horizontal = 16.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp), horizontalArrangement = Arrangement.spacedBy(8.dp),
) { ) {
FilledTonalButton(onClick = { viewModel.adjustCustomBar(-2.5) }) { Text("2,5") } androidx.compose.material3.OutlinedTextField(
Text( value = newBar,
"${if (settings.customBarWeight % 1.0 == 0.0) settings.customBarWeight.toInt().toString() else settings.customBarWeight} kg", onValueChange = { newBar = it },
style = MaterialTheme.typography.headlineSmall, label = { Text("Ny stångvikt (t.ex. 13,7)") },
fontWeight = FontWeight.Bold, singleLine = true,
keyboardOptions = androidx.compose.foundation.text.KeyboardOptions(
keyboardType = androidx.compose.ui.text.input.KeyboardType.Decimal,
),
modifier = Modifier.weight(1f), 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( Text(
"Snabbvalen 20 kg och utan stång finns alltid i kalkylatorn" + "Snabbvalen 20 kg och utan stång finns alltid i kalkylatorn.",
"det här är det tredje alternativet (t.ex. lättare stång).",
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(start = 16.dp, end = 16.dp, bottom = 12.dp), 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() { class StatsViewModel(private val repo: GymRepository) : ViewModel() {
val period = MutableStateFlow("month") val period = MutableStateFlow("month")
val uiState = MutableStateFlow<StatsUiState>(StatsUiState.Loading) 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) { fun setPeriod(id: String) {
if (period.value == id) return 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) { if (stats.heaviestLift != null || stats.newPrCount > 0) {
item { item {
Card(modifier = Modifier.fillMaxWidth()) { 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)))
}
}
}