Helnamn i hälsningen, flera egna stänger, kroppsvikt med historik och graf
All checks were successful
release / build-release (push) Successful in 5m16s
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:
@@ -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>)
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
)
|
||||
|
||||
@@ -181,7 +181,7 @@ fun SessionScreen(
|
||||
plateWeight?.let { weight ->
|
||||
PlateCalculatorSheet(
|
||||
weightKg = weight,
|
||||
customBarWeight = uiState.settings.customBarWeight,
|
||||
customBarWeights = uiState.settings.customBarWeights,
|
||||
onDismiss = { plateWeight = null },
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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)))
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user