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)
|
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"),
|
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å
|
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
|
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`).
|
(kod: `ui/common/LiftStatusSheet.kt`, API: `setLiftStatus`/`setSessionSetStatus`).
|
||||||
|
|
||||||
|
|||||||
@@ -330,6 +330,47 @@ class GymRepository(
|
|||||||
}.sortedWith(compareByDescending<PbEntry> { it.isFavorite }.thenBy { it.exerciseName })
|
}.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). */
|
/** 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> {
|
suspend fun pbCandidates(exerciseTypeId: Int, reps: Int, mode: String = "all", limit: Int = 8): List<LiftDetail> {
|
||||||
val d = client.execute(
|
val d = client.execute(
|
||||||
@@ -953,6 +994,31 @@ data class PbEntry(
|
|||||||
val isFavorite: Boolean = false,
|
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). */
|
/** Ett lyft med allt PB-arket behöver (pbCandidates / liftDetail). */
|
||||||
data class LiftDetail(
|
data class LiftDetail(
|
||||||
val id: Int,
|
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()) {
|
if (pbs.isEmpty()) {
|
||||||
item {
|
item {
|
||||||
Text(
|
Text(
|
||||||
|
|||||||
Reference in New Issue
Block a user