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
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:
@@ -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`).
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user