Statistik: Sök i rekorden — övning + vikt→reps eller reps→vikt, exakt/närmast, 1RM-uppskattning
All checks were successful
release / build-release (push) Successful in 4m21s

PbSearchCard.kt ovanför PB-listan, GymRepository.pbLookup. Träffar öppnar LiftStatusSheet.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AFvSx1pF9pymyEuK7MFkEG
This commit is contained in:
2026-09-07 20:25:08 +02:00
parent 8f7cd9a419
commit 6ce8853309
4 changed files with 267 additions and 1 deletions

View File

@@ -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`).

View File

@@ -330,6 +330,47 @@ class GymRepository(
}.sortedWith(compareByDescending<PbEntry> { 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<LiftDetail> {
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<PbLookupHit>,
val byReps: List<PbLookupHit>,
)
/** Ett lyft med allt PB-arket behöver (pbCandidates / liftDetail). */
data class LiftDetail(
val id: Int,

View File

@@ -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<PbEntry>,
mode: String,
repo: GymRepository,
onOpenLift: (LiftStatusTarget) -> Unit,
) {
val scope = rememberCoroutineScope()
var expanded by remember { mutableStateOf(false) }
var selected by remember { mutableStateOf<PbEntry?>(null) }
var byWeight by remember { mutableStateOf(true) }
var value by remember { mutableStateOf("") }
var loading by remember { mutableStateOf(false) }
var error by remember { mutableStateOf<String?>(null) }
var result by remember { mutableStateOf<PbLookup?>(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,
)
}
}
}

View File

@@ -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(