From 6ce88533096dbf77e1c7d90b64551221b4879600 Mon Sep 17 00:00:00 2001 From: claude Date: Mon, 7 Sep 2026 20:25:08 +0200 Subject: [PATCH] =?UTF-8?q?Statistik:=20S=C3=B6k=20i=20rekorden=20?= =?UTF-8?q?=E2=80=94=20=C3=B6vning=20+=20vikt=E2=86=92reps=20eller=20reps?= =?UTF-8?q?=E2=86=92vikt,=20exakt/n=C3=A4rmast,=201RM-uppskattning?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PbSearchCard.kt ovanför PB-listan, GymRepository.pbLookup. Träffar öppnar LiftStatusSheet. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01AFvSx1pF9pymyEuK7MFkEG --- README.md | 4 +- .../fitnessdroid/data/GymRepository.kt | 66 ++++++ .../fitnessdroid/ui/stats/PbSearchCard.kt | 188 ++++++++++++++++++ .../fitnessdroid/ui/stats/StatsScreen.kt | 10 + 4 files changed, 267 insertions(+), 1 deletion(-) create mode 100644 app/src/main/java/eu/brassepc/fitnessdroid/ui/stats/PbSearchCard.kt diff --git a/README.md b/README.md index 3144b88..97936f0 100644 --- a/README.md +++ b/README.md @@ -53,7 +53,9 @@ ett rekord så öppnas ett ark med vikt, est. 1RM, datum, pass och anteckning, d märkas som **Träning**, **Tävling** eller **Räknas ej** (fusk, felregistrerat, ej godkänt) med en kommentar. Under arket visas topplistan för samma övning × reps ("näst i tur"), så ett bortplockat lyft kan väljas och återställas. Samma ark öppnas när man trycker på -ett set i passhistoriken. Stjärnan på ett PB-kort gör övningen till **favorit** (samma +ett set i passhistoriken. Kortet **Sök i rekorden** överst i Personbästa svarar på "hur många reps på X kg?" eller +"hur tungt på N reps?" – exakt träff eller närmast under/över, plus en uppskattning via +bästa est. 1RM. Stjärnan på ett PB-kort gör övningen till **favorit** (samma favoriter som övningsväljaren) så att den ligger överst i PB-listan i både app och webb. "Räknas ej" tas ur rekord, 1RM och PB-höjdpunkter på servern (kod: `ui/common/LiftStatusSheet.kt`, API: `setLiftStatus`/`setSessionSetStatus`). 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 c569bc2..605cb0a 100644 --- a/app/src/main/java/eu/brassepc/fitnessdroid/data/GymRepository.kt +++ b/app/src/main/java/eu/brassepc/fitnessdroid/data/GymRepository.kt @@ -330,6 +330,47 @@ class GymRepository( }.sortedWith(compareByDescending { it.isFavorite }.thenBy { it.exerciseName }) } + /** + * PB-sökning: reps vid given vikt eller vikt vid givna reps – exakt träff eller + * närmast under/över, plus hela profilen och en Epley-uppskattning. + */ + suspend fun pbLookup(exerciseTypeId: Int, weight: Double? = null, reps: Int? = null, mode: String = "all"): PbLookup { + val hit = "weight reps date liftId status statusNote sessionName estimated1Rm count" + val d = client.execute( + "query(\$e:Int!,\$w:Float,\$r:Int,\$m:String){pbLookup(exerciseTypeId:\$e,weight:\$w,reps:\$r,mode:\$m){exerciseTypeId exerciseTypeName mode queryWeight queryReps estimatedReps estimatedWeight bestEstimated1Rm exact{$hit} below{$hit} above{$hit} byWeight{$hit} byReps{$hit}}}", + buildJsonObject { + put("e", exerciseTypeId); weight?.let { put("w", it) }; reps?.let { put("r", it) }; put("m", mode) + }, + auth.bearerToken(), + ) + val o = d["pbLookup"]!!.jsonObject + fun hitOf(key: String): PbLookupHit? = o[key]?.takeIf { it !is kotlinx.serialization.json.JsonNull }?.jsonObject?.let(::parseLookupHit) + return PbLookup( + exerciseTypeId = o["exerciseTypeId"]?.jsonPrimitive?.intOrNull ?: exerciseTypeId, + exerciseName = o["exerciseTypeName"]?.jsonPrimitive?.contentOrNull() ?: "", + queryWeight = o["queryWeight"]?.jsonPrimitive?.doubleOrNull, + queryReps = o["queryReps"]?.jsonPrimitive?.intOrNull, + exact = hitOf("exact"), + below = hitOf("below"), + above = hitOf("above"), + estimatedReps = o["estimatedReps"]?.jsonPrimitive?.doubleOrNull, + estimatedWeight = o["estimatedWeight"]?.jsonPrimitive?.doubleOrNull, + bestEstimated1Rm = o["bestEstimated1Rm"]?.jsonPrimitive?.doubleOrNull, + byWeight = o["byWeight"]?.jsonArray?.map { parseLookupHit(it.jsonObject) }.orEmpty(), + byReps = o["byReps"]?.jsonArray?.map { parseLookupHit(it.jsonObject) }.orEmpty(), + ) + } + + private fun parseLookupHit(h: kotlinx.serialization.json.JsonObject) = PbLookupHit( + weight = h["weight"]?.jsonPrimitive?.doubleOrNull ?: 0.0, + reps = h["reps"]?.jsonPrimitive?.intOrNull ?: 0, + date = h["date"]?.jsonPrimitive?.contentOrNull()?.take(10) ?: "", + liftId = h["liftId"]?.jsonPrimitive?.intOrNull ?: 0, + status = h["status"]?.jsonPrimitive?.contentOrNull() ?: "training", + sessionName = h["sessionName"]?.jsonPrimitive?.contentOrNull(), + count = h["count"]?.jsonPrimitive?.intOrNull ?: 1, + ) + /** Topplista för övning × reps, alla statusar (så bortplockade kan återställas). */ suspend fun pbCandidates(exerciseTypeId: Int, reps: Int, mode: String = "all", limit: Int = 8): List { val d = client.execute( @@ -953,6 +994,31 @@ data class PbEntry( val isFavorite: Boolean = false, ) +data class PbLookupHit( + val weight: Double, + val reps: Int, + val date: String, + val liftId: Int, + val status: String, + val sessionName: String?, + val count: Int, +) + +data class PbLookup( + val exerciseTypeId: Int, + val exerciseName: String, + val queryWeight: Double?, + val queryReps: Int?, + val exact: PbLookupHit?, + val below: PbLookupHit?, + val above: PbLookupHit?, + val estimatedReps: Double?, + val estimatedWeight: Double?, + val bestEstimated1Rm: Double?, + val byWeight: List, + val byReps: List, +) + /** Ett lyft med allt PB-arket behöver (pbCandidates / liftDetail). */ data class LiftDetail( val id: Int, diff --git a/app/src/main/java/eu/brassepc/fitnessdroid/ui/stats/PbSearchCard.kt b/app/src/main/java/eu/brassepc/fitnessdroid/ui/stats/PbSearchCard.kt new file mode 100644 index 0000000..76bfc5e --- /dev/null +++ b/app/src/main/java/eu/brassepc/fitnessdroid/ui/stats/PbSearchCard.kt @@ -0,0 +1,188 @@ +package eu.brassepc.fitnessdroid.ui.stats + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExposedDropdownMenuBox +import androidx.compose.material3.ExposedDropdownMenuDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.SegmentedButton +import androidx.compose.material3.SegmentedButtonDefaults +import androidx.compose.material3.SingleChoiceSegmentedButtonRow +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import eu.brassepc.fitnessdroid.data.GymRepository +import eu.brassepc.fitnessdroid.data.PbEntry +import eu.brassepc.fitnessdroid.data.PbLookup +import eu.brassepc.fitnessdroid.data.PbLookupHit +import eu.brassepc.fitnessdroid.ui.common.LiftStatusTarget +import eu.brassepc.fitnessdroid.ui.common.compact +import kotlinx.coroutines.launch + +/** + * "Sök i rekorden": välj övning och fråga antingen hur många reps du max gjort på + * en vikt, eller hur tungt du lyft på ett repsantal. Exakt träff, annars närmast + * under/över. Tryck på en träff öppnar LiftStatusSheet via [onOpenLift]. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun PbSearchCard( + exercises: List, + mode: String, + repo: GymRepository, + onOpenLift: (LiftStatusTarget) -> Unit, +) { + val scope = rememberCoroutineScope() + var expanded by remember { mutableStateOf(false) } + var selected by remember { mutableStateOf(null) } + var byWeight by remember { mutableStateOf(true) } + var value by remember { mutableStateOf("") } + var loading by remember { mutableStateOf(false) } + var error by remember { mutableStateOf(null) } + var result by remember { mutableStateOf(null) } + + val number = value.replace(',', '.').toDoubleOrNull() + val canSearch = selected != null && number != null && number > 0 + + fun search() { + val ex = selected ?: return + val n = number ?: return + scope.launch { + loading = true + error = null + try { + result = if (byWeight) repo.pbLookup(ex.exerciseTypeId, weight = n, mode = mode) + else repo.pbLookup(ex.exerciseTypeId, reps = n.toInt(), mode = mode) + } catch (e: Exception) { + error = "Kunde inte söka — offline?" + } finally { + loading = false + } + } + } + + Card(modifier = Modifier.fillMaxWidth()) { + Column(modifier = Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) { + Text("Sök i rekorden", style = MaterialTheme.typography.titleSmall) + Text( + "Hur många reps på en vikt, eller hur tungt på ett repsantal? Utan exakt träff visas närmast under och över.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + ExposedDropdownMenuBox(expanded = expanded, onExpandedChange = { expanded = it }) { + OutlinedTextField( + value = selected?.let { (if (it.isFavorite) "★ " else "") + it.exerciseName } ?: "", + onValueChange = {}, + readOnly = true, + label = { Text("Övning") }, + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) }, + modifier = Modifier.fillMaxWidth().menuAnchor(), + ) + ExposedDropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { + exercises.forEach { e -> + DropdownMenuItem( + text = { Text((if (e.isFavorite) "★ " else "") + e.exerciseName) }, + onClick = { selected = e; expanded = false; result = null }, + ) + } + } + } + + SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) { + SegmentedButton(selected = byWeight, onClick = { byWeight = true; result = null }, shape = SegmentedButtonDefaults.itemShape(0, 2)) { Text("Vikt → reps") } + SegmentedButton(selected = !byWeight, onClick = { byWeight = false; result = null }, shape = SegmentedButtonDefaults.itemShape(1, 2)) { Text("Reps → vikt") } + } + + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) { + OutlinedTextField( + value = value, + onValueChange = { value = it }, + label = { Text(if (byWeight) "Vikt (kg)" else "Antal reps") }, + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal), + modifier = Modifier.weight(1f), + ) + Button(onClick = { search() }, enabled = canSearch && !loading) { Text(if (loading) "Söker…" else "Sök") } + } + + error?.let { Text(it, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall) } + + result?.let { r -> + val q = if (r.queryWeight != null) "${r.queryWeight.compact()} kg" else "${r.queryReps} reps" + Text("${r.exerciseName} · $q", style = MaterialTheme.typography.labelLarge) + if (r.byWeight.isEmpty()) { + Text("Inga giltiga lyft på övningen i valt läge.", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } else { + val exact = r.exact + if (exact != null) { + HitCard("🎯 Exakt träff", exact, byWeight, MaterialTheme.colorScheme.primaryContainer) { onOpenLift(target(r, exact)) } + } else { + Text("Inget lyft på exakt $q. Närmast:", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + r.below?.let { HitCard("▼ Under", it, byWeight, MaterialTheme.colorScheme.surfaceVariant) { onOpenLift(target(r, it)) } } + ?: Text("▼ Under: inget lättare", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + r.above?.let { HitCard("▲ Över", it, byWeight, MaterialTheme.colorScheme.surfaceVariant) { onOpenLift(target(r, it)) } } + ?: Text("▲ Över: inget tyngre", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + val est = r.bestEstimated1Rm?.let { rm -> + if (byWeight && r.estimatedReps != null && r.queryWeight != null) "📐 Uppskattat via bästa est. 1RM (${rm.compact()} kg): ~${r.estimatedReps.compact()} reps på ${r.queryWeight.compact()} kg." + else if (!byWeight && r.estimatedWeight != null) "📐 Uppskattat via bästa est. 1RM (${rm.compact()} kg): ~${r.estimatedWeight.compact()} kg på ${r.queryReps} reps." + else null + } + est?.let { Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) } + } + } + } + } +} + +private fun target(r: PbLookup, h: PbLookupHit) = + LiftStatusTarget.Lift(exerciseTypeId = r.exerciseTypeId, exerciseName = r.exerciseName, reps = h.reps, liftId = h.liftId) + +@Composable +private fun HitCard(label: String, h: PbLookupHit, byWeight: Boolean, container: androidx.compose.ui.graphics.Color, onClick: () -> Unit) { + Card( + modifier = Modifier.fillMaxWidth().clickable(onClick = onClick), + colors = CardDefaults.cardColors(containerColor = container), + ) { + Column(modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp), verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text(label, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + Text( + if (byWeight) "${h.reps} reps på ${h.weight.compact()} kg" else "${h.weight.compact()} kg × ${h.reps}", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary, + ) + Text( + buildString { + append(h.date) + h.sessionName?.let { append(" · pass \"$it\"") } ?: append(" · löst lyft") + if (h.count > 1) append(" · ${h.count} lyft i gruppen") + if (h.status == "competition") append(" · 🎖️ tävling") + }, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} 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 1fc04a4..f0f5ce6 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 @@ -407,6 +407,16 @@ fun StatsScreen(viewModel: StatsViewModel = viewModel(factory = StatsViewModel.F } } } + if (pbs.isNotEmpty()) { + item { + PbSearchCard( + exercises = pbs, + mode = pbMode, + repo = viewModel.repo, + onOpenLift = { pbSheet = it }, + ) + } + } if (pbs.isEmpty()) { item { Text(