From e2ffb4f011f7e41672c08e122443c374d295d07e Mon Sep 17 00:00:00 2001 From: claude Date: Sat, 25 Jul 2026 17:51:38 +0200 Subject: [PATCH] =?UTF-8?q?Helnamn=20i=20h=C3=A4lsningen,=20flera=20egna?= =?UTF-8?q?=20st=C3=A4nger,=20kroppsvikt=20med=20historik=20och=20graf?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 Claude-Session: https://claude.ai/code/session_01Kf4MAbZ3c3B4Xy5XfuGsCP --- .../fitnessdroid/data/GymRepository.kt | 78 ++++++- .../fitnessdroid/data/SettingsStore.kt | 28 ++- .../brassepc/fitnessdroid/data/TokenStore.kt | 10 + .../fitnessdroid/ui/home/HomeScreen.kt | 6 +- .../fitnessdroid/ui/home/HomeViewModel.kt | 4 + .../fitnessdroid/ui/profile/ProfileScreen.kt | 192 +++++++++++++++++- .../ui/session/PlateCalculator.kt | 15 +- .../fitnessdroid/ui/session/SessionScreen.kt | 2 +- .../ui/settings/SettingsScreen.kt | 63 ++++-- .../fitnessdroid/ui/stats/StatsScreen.kt | 57 +++++- .../fitnessdroid/ui/stats/WeightGraph.kt | 87 ++++++++ 11 files changed, 502 insertions(+), 40 deletions(-) create mode 100644 app/src/main/java/eu/brassepc/fitnessdroid/ui/stats/WeightGraph.kt diff --git a/app/src/main/java/eu/brassepc/fitnessdroid/data/GymRepository.kt b/app/src/main/java/eu/brassepc/fitnessdroid/data/GymRepository.kt index fd2301e..916d08d 100644 --- a/app/src/main/java/eu/brassepc/fitnessdroid/data/GymRepository.kt +++ b/app/src/main/java/eu/brassepc/fitnessdroid/data/GymRepository.kt @@ -160,6 +160,67 @@ class GymRepository( } suspend fun bodyWeightKg(): Double? = store.bodyWeightKg() + suspend fun displayName(): String? = store.displayName() + + /* ---------- Kroppsmätningar ---------- */ + + suspend fun fetchBodyMeasurements(): List { + 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, 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 = 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) diff --git a/app/src/main/java/eu/brassepc/fitnessdroid/data/SettingsStore.kt b/app/src/main/java/eu/brassepc/fitnessdroid/data/SettingsStore.kt index 305f2e5..c0ccea9 100644 --- a/app/src/main/java/eu/brassepc/fitnessdroid/data/SettingsStore.kt +++ b/app/src/main/java/eu/brassepc/fitnessdroid/data/SettingsStore.kt @@ -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 = 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") } } diff --git a/app/src/main/java/eu/brassepc/fitnessdroid/data/TokenStore.kt b/app/src/main/java/eu/brassepc/fitnessdroid/data/TokenStore.kt index ea9fe34..1ec6544 100644 --- a/app/src/main/java/eu/brassepc/fitnessdroid/data/TokenStore.kt +++ b/app/src/main/java/eu/brassepc/fitnessdroid/data/TokenStore.kt @@ -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") } } diff --git a/app/src/main/java/eu/brassepc/fitnessdroid/ui/home/HomeScreen.kt b/app/src/main/java/eu/brassepc/fitnessdroid/ui/home/HomeScreen.kt index 23276d7..1674013 100644 --- a/app/src/main/java/eu/brassepc/fitnessdroid/ui/home/HomeScreen.kt +++ b/app/src/main/java/eu/brassepc/fitnessdroid/ui/home/HomeScreen.kt @@ -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, diff --git a/app/src/main/java/eu/brassepc/fitnessdroid/ui/home/HomeViewModel.kt b/app/src/main/java/eu/brassepc/fitnessdroid/ui/home/HomeViewModel.kt index 4808ad7..aa38662 100644 --- a/app/src/main/java/eu/brassepc/fitnessdroid/ui/home/HomeViewModel.kt +++ b/app/src/main/java/eu/brassepc/fitnessdroid/ui/home/HomeViewModel.kt @@ -38,8 +38,12 @@ class HomeViewModel( val updateState = kotlinx.coroutines.flow.MutableStateFlow(null) + /** Helnamn från profilen — trevligare hälsning än användarnamnet. */ + val displayName = kotlinx.coroutines.flow.MutableStateFlow(null) + init { viewModelScope.launch { + displayName.value = repo.displayName() updateChecker.check()?.let { updateState.value = UpdateState.Available(it) } } } diff --git a/app/src/main/java/eu/brassepc/fitnessdroid/ui/profile/ProfileScreen.kt b/app/src/main/java/eu/brassepc/fitnessdroid/ui/profile/ProfileScreen.kt index b803702..70ad2d0 100644 --- a/app/src/main/java/eu/brassepc/fitnessdroid/ui/profile/ProfileScreen.kt +++ b/app/src/main/java/eu/brassepc/fitnessdroid/ui/profile/ProfileScreen.kt @@ -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(null) + val measurements = MutableStateFlow>(emptyList()) + val message = MutableStateFlow(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(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()) { diff --git a/app/src/main/java/eu/brassepc/fitnessdroid/ui/session/PlateCalculator.kt b/app/src/main/java/eu/brassepc/fitnessdroid/ui/session/PlateCalculator.kt index 51e7a56..e2aeb9d 100644 --- a/app/src/main/java/eu/brassepc/fitnessdroid/ui/session/PlateCalculator.kt +++ b/app/src/main/java/eu/brassepc/fitnessdroid/ui/session/PlateCalculator.kt @@ -57,15 +57,11 @@ private fun breakdown(perSideGrams: Int): Pair, Int> { @Composable fun PlateCalculatorSheet( weightKg: Double, - customBarWeight: Double, + customBarWeights: List, 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" } ) diff --git a/app/src/main/java/eu/brassepc/fitnessdroid/ui/session/SessionScreen.kt b/app/src/main/java/eu/brassepc/fitnessdroid/ui/session/SessionScreen.kt index f741888..ecba1a4 100644 --- a/app/src/main/java/eu/brassepc/fitnessdroid/ui/session/SessionScreen.kt +++ b/app/src/main/java/eu/brassepc/fitnessdroid/ui/session/SessionScreen.kt @@ -181,7 +181,7 @@ fun SessionScreen( plateWeight?.let { weight -> PlateCalculatorSheet( weightKg = weight, - customBarWeight = uiState.settings.customBarWeight, + customBarWeights = uiState.settings.customBarWeights, onDismiss = { plateWeight = null }, ) } diff --git a/app/src/main/java/eu/brassepc/fitnessdroid/ui/settings/SettingsScreen.kt b/app/src/main/java/eu/brassepc/fitnessdroid/ui/settings/SettingsScreen.kt index 22dafb0..8cb4d33 100644 --- a/app/src/main/java/eu/brassepc/fitnessdroid/ui/settings/SettingsScreen.kt +++ b/app/src/main/java/eu/brassepc/fitnessdroid/ui/settings/SettingsScreen.kt @@ -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), diff --git a/app/src/main/java/eu/brassepc/fitnessdroid/ui/stats/StatsScreen.kt b/app/src/main/java/eu/brassepc/fitnessdroid/ui/stats/StatsScreen.kt index 89f3f06..4cf6926 100644 --- a/app/src/main/java/eu/brassepc/fitnessdroid/ui/stats/StatsScreen.kt +++ b/app/src/main/java/eu/brassepc/fitnessdroid/ui/stats/StatsScreen.kt @@ -57,8 +57,14 @@ sealed interface StatsUiState { class StatsViewModel(private val repo: GymRepository) : ViewModel() { val period = MutableStateFlow("month") val uiState = MutableStateFlow(StatsUiState.Loading) + val bodyMeasurements = MutableStateFlow>(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()) { diff --git a/app/src/main/java/eu/brassepc/fitnessdroid/ui/stats/WeightGraph.kt b/app/src/main/java/eu/brassepc/fitnessdroid/ui/stats/WeightGraph.kt new file mode 100644 index 0000000..7d4d9ce --- /dev/null +++ b/app/src/main/java/eu/brassepc/fitnessdroid/ui/stats/WeightGraph.kt @@ -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, + 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))) + } + } +}