From 139a4c20ef9ee64831b494eb7a9186da6c956ce0 Mon Sep 17 00:00:00 2001 From: claude Date: Sun, 6 Sep 2026 09:16:02 +0200 Subject: [PATCH] =?UTF-8?q?Rekordstatus:=20tr=C3=A4ning/t=C3=A4vling/r?= =?UTF-8?q?=C3=A4knas=20ej=20p=C3=A5=20lyft=20+=20PB-l=C3=A4gen=20och=20de?= =?UTF-8?q?taljark?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ui/common/LiftStatusSheet.kt: ModalBottomSheet med vikt, est. 1RM, datum, pass, anteckning, SegmentedButtons (Träning/Tävling/Räknas ej), kommentar, Spara och topplista "näst i tur" för samma övning × reps (alla statusar, så bortplockade lyft kan återställas). Lyft- och set-läge. - Statistik → Personbästa: chips Alla giltiga · Tävling · Träning, ett AssistChip per rekord (🎖️ på tävling) som öppnar arket. - Passhistorik: tryck på ett set öppnar arket i set-läge; statusmärke visas. - GymRepository: personalBestsDetailed(mode), pbCandidates, liftDetail, setLiftStatus, setSessionSetStatus; PB-cachen uppdateras efter ändring. HistorySet/SessionDetailExercise bär id, notes, status, exerciseTypeId. Kräver gym-api med migration 20260906090000_AddLiftStatus. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01AFvSx1pF9pymyEuK7MFkEG --- README.md | 10 + .../fitnessdroid/data/GymRepository.kt | 210 ++++++++++--- .../fitnessdroid/ui/common/LiftStatusSheet.kt | 280 ++++++++++++++++++ .../ui/history/HistoryDetailScreen.kt | 50 +++- .../fitnessdroid/ui/stats/StatsScreen.kt | 101 ++++++- 5 files changed, 588 insertions(+), 63 deletions(-) create mode 100644 app/src/main/java/eu/brassepc/fitnessdroid/ui/common/LiftStatusSheet.kt diff --git a/README.md b/README.md index e131a8a..c88e4e3 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,16 @@ direkt på en ansluten telefon med USB-felsökning på. Logga in med ditt vanliga gym-konto (LDAP). API-url:en går att ändra under "Avancerat" på inloggningsskärmen (standard: `https://gymapi.brasse-pc.eu/graphql`). +## Rekordstatus: träning / tävling / räknas ej + +Under **Statistik → Personbästa** finns växeln *Alla giltiga · Tävling · Träning*. Tryck på +ett rekord så öppnas ett ark med vikt, est. 1RM, datum, pass och anteckning, där lyftet kan +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. "Räknas ej" tas ur rekord, 1RM och PB-höjdpunkter på servern +(kod: `ui/common/LiftStatusSheet.kt`, API: `setLiftStatus`/`setSessionSetStatus`). + ## Signeringsnyckeln Nyckeln är **inte** incheckad (repot är publikt). Lokalt ligger den i 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 b457b7b..43bc19b 100644 --- a/app/src/main/java/eu/brassepc/fitnessdroid/data/GymRepository.kt +++ b/app/src/main/java/eu/brassepc/fitnessdroid/data/GymRepository.kt @@ -25,6 +25,9 @@ import kotlinx.serialization.json.put * Lokal-först-repository. Allt UI läser Room-flows; alla skrivningar sker * lokalt först och läggs på op-kön som [SyncEngine] driver mot servern. */ +private const val LIFT_DETAIL_FIELDS = + "id exerciseTypeId exerciseTypeName weight reps date notes status statusNote gymSessionId sessionName gymSessionSetId estimated1Rm isCurrentRecord" + class GymRepository( private val db: AppDatabase, private val client: GraphQlClient, @@ -265,6 +268,123 @@ class GymRepository( suspend fun personalBests(): Map, Double> = db.cacheDao().personalBests().associate { (it.exerciseTypeId to it.reps) to it.weight } + + /** PB-cachen (för 🏆-märkning av set under pass) – bara giltiga lyft, alla lägen. */ + suspend fun refreshPbCache(token: String? = null) { + val tok = token ?: auth.bearerToken() + + val pbs = client.execute( + "query{personalBests{exerciseTypeId repRecords{reps weight}}}", + token = tok, + ) + val rows = pbs["personalBests"]!!.jsonArray.flatMap { p -> + val po = p.jsonObject + val typeId = po["exerciseTypeId"]!!.jsonPrimitive.int + po["repRecords"]!!.jsonArray.mapNotNull { r -> + val ro = r.jsonObject + val reps = ro["reps"]?.jsonPrimitive?.intOrNull ?: return@mapNotNull null + val weight = ro["weight"]?.jsonPrimitive?.doubleOrNull ?: return@mapNotNull null + eu.brassepc.fitnessdroid.data.local.CachedPb( + key = "$typeId-$reps", + exerciseTypeId = typeId, + reps = reps, + weight = weight, + ) + } + } + db.cacheDao().clearPersonalBests() + db.cacheDao().upsertPersonalBests(rows) + + } + + /* ---------- Rekordstatus (träning / tävling / räknas ej) ---------- */ + + /** PB-lista med detaljer per rekord. mode: all | competition | training. */ + suspend fun personalBestsDetailed(mode: String = "all"): List { + val pb = client.execute( + "query(\$m:String){personalBests(mode:\$m){exerciseTypeId exerciseTypeName repRecords{reps weight date liftId notes status statusNote sessionName estimated1Rm}}}", + buildJsonObject { put("m", mode) }, + auth.bearerToken(), + ) + return pb["personalBests"]!!.jsonArray.map { p -> + val po = p.jsonObject + PbEntry( + exerciseName = po["exerciseTypeName"]?.jsonPrimitive?.contentOrNull() ?: "Övning", + exerciseTypeId = po["exerciseTypeId"]?.jsonPrimitive?.intOrNull ?: 0, + records = po["repRecords"]!!.jsonArray.mapNotNull { r -> + val ro = r.jsonObject + PbRecord( + reps = ro["reps"]?.jsonPrimitive?.intOrNull ?: return@mapNotNull null, + weight = ro["weight"]?.jsonPrimitive?.doubleOrNull ?: return@mapNotNull null, + date = ro["date"]?.jsonPrimitive?.contentOrNull()?.take(10) ?: "", + liftId = ro["liftId"]?.jsonPrimitive?.intOrNull ?: 0, + status = ro["status"]?.jsonPrimitive?.contentOrNull() ?: "training", + statusNote = ro["statusNote"]?.jsonPrimitive?.contentOrNull(), + notes = ro["notes"]?.jsonPrimitive?.contentOrNull(), + sessionName = ro["sessionName"]?.jsonPrimitive?.contentOrNull(), + est1Rm = ro["estimated1Rm"]?.jsonPrimitive?.doubleOrNull ?: 0.0, + ) + }.sortedBy { it.reps }, + ) + }.sortedBy { it.exerciseName } + } + + /** 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( + "query(\$e:Int!,\$r:Int!,\$l:Int,\$m:String){pbCandidates(exerciseTypeId:\$e,reps:\$r,limit:\$l,mode:\$m){$LIFT_DETAIL_FIELDS}}", + buildJsonObject { put("e", exerciseTypeId); put("r", reps); put("l", limit); put("m", mode) }, + auth.bearerToken(), + ) + return d["pbCandidates"]!!.jsonArray.map { parseLiftDetail(it.jsonObject) } + } + + suspend fun liftDetail(id: Int): LiftDetail? { + val d = client.execute( + "query(\$id:Int!){liftDetail(id:\$id){$LIFT_DETAIL_FIELDS}}", + buildJsonObject { put("id", id) }, + auth.bearerToken(), + ) + return d["liftDetail"]?.takeIf { it !is kotlinx.serialization.json.JsonNull }?.let { parseLiftDetail(it.jsonObject) } + } + + /** Sätter status på ett lyft (och dess set). Uppdaterar PB-cachen efteråt. */ + suspend fun setLiftStatus(id: Int, status: String, note: String?) { + client.execute( + "mutation(\$input:SetLiftStatusInput!){setLiftStatus(input:\$input){id status statusNote}}", + buildJsonObject { put("input", buildJsonObject { put("id", id); put("status", status); note?.let { put("note", it) } }) }, + auth.bearerToken(), + ) + runCatching { refreshPbCache() } + } + + /** Sätter status på ett set i ett pass (och dess lyft om passet är avslutat). */ + suspend fun setSessionSetStatus(setId: Int, status: String, note: String?) { + client.execute( + "mutation(\$input:SetSessionSetStatusInput!){setSessionSetStatus(input:\$input){id status statusNote}}", + buildJsonObject { put("input", buildJsonObject { put("setId", setId); put("status", status); note?.let { put("note", it) } }) }, + auth.bearerToken(), + ) + runCatching { refreshPbCache() } + } + + private fun parseLiftDetail(o: kotlinx.serialization.json.JsonObject) = LiftDetail( + id = o["id"]!!.jsonPrimitive.int, + exerciseTypeId = o["exerciseTypeId"]?.jsonPrimitive?.intOrNull ?: 0, + exerciseName = o["exerciseTypeName"]?.jsonPrimitive?.contentOrNull() ?: "", + weight = o["weight"]?.jsonPrimitive?.doubleOrNull ?: 0.0, + reps = o["reps"]?.jsonPrimitive?.intOrNull ?: 0, + date = o["date"]?.jsonPrimitive?.contentOrNull()?.take(10) ?: "", + notes = o["notes"]?.jsonPrimitive?.contentOrNull(), + status = o["status"]?.jsonPrimitive?.contentOrNull() ?: "training", + statusNote = o["statusNote"]?.jsonPrimitive?.contentOrNull(), + sessionId = o["gymSessionId"]?.jsonPrimitive?.intOrNull, + sessionName = o["sessionName"]?.jsonPrimitive?.contentOrNull(), + setId = o["gymSessionSetId"]?.jsonPrimitive?.intOrNull, + est1Rm = o["estimated1Rm"]?.jsonPrimitive?.doubleOrNull ?: 0.0, + isCurrentRecord = o["isCurrentRecord"]?.jsonPrimitive?.booleanOrNull ?: false, + ) + /* ---------- Referensdata / cache ---------- */ /** Hämta om all referensdata. Tyst vid nätfel — cachen gäller tills vidare. */ @@ -314,29 +434,7 @@ class GymRepository( ) } - runCatching { - val pbs = client.execute( - "query{personalBests{exerciseTypeId repRecords{reps weight}}}", - token = token, - ) - val rows = pbs["personalBests"]!!.jsonArray.flatMap { p -> - val po = p.jsonObject - val typeId = po["exerciseTypeId"]!!.jsonPrimitive.int - po["repRecords"]!!.jsonArray.mapNotNull { r -> - val ro = r.jsonObject - val reps = ro["reps"]?.jsonPrimitive?.intOrNull ?: return@mapNotNull null - val weight = ro["weight"]?.jsonPrimitive?.doubleOrNull ?: return@mapNotNull null - eu.brassepc.fitnessdroid.data.local.CachedPb( - key = "$typeId-$reps", - exerciseTypeId = typeId, - reps = reps, - weight = weight, - ) - } - } - db.cacheDao().clearPersonalBests() - db.cacheDao().upsertPersonalBests(rows) - } + runCatching { refreshPbCache(token) } runCatching { val prof = client.execute("query{myProfile{bodyWeightKg displayName}}", token = token) @@ -520,7 +618,7 @@ class GymRepository( /** Full detalj för ett avslutat pass. */ suspend fun fetchSessionDetail(id: Int): SessionDetail { val data = client.execute( - "query(\$id:Int!){gymSession(id:\$id){id name notes date startTime endTime durationSecondsOverride estimatedCalories gymSessionExercises{order exerciseType{name} sets{order reps weight distanceMeters durationSeconds rpe isWarmup}}}}", + "query(\$id:Int!){gymSession(id:\$id){id name notes date startTime endTime durationSecondsOverride estimatedCalories gymSessionExercises{order exerciseType{id name} sets{id order reps weight distanceMeters durationSeconds rpe isWarmup notes status statusNote}}}}", buildJsonObject { put("id", id) }, auth.bearerToken(), ) @@ -546,6 +644,7 @@ class GymRepository( ?: "Övning", sets = eo["sets"]!!.jsonArray.map(::parseHistorySet) .sortedBy { it.order }, + exerciseTypeId = eo["exerciseType"]?.jsonObject?.get("id")?.jsonPrimitive?.intOrNull ?: 0, ) }, ) @@ -603,6 +702,10 @@ class GymRepository( durationSeconds = so["durationSeconds"]?.jsonPrimitive?.intOrNull, rpe = so["rpe"]?.jsonPrimitive?.doubleOrNull, isWarmup = so["isWarmup"]?.jsonPrimitive?.booleanOrNull ?: false, + id = so["id"]?.jsonPrimitive?.intOrNull ?: 0, + notes = so["notes"]?.jsonPrimitive?.contentOrNull(), + status = so["status"]?.jsonPrimitive?.contentOrNull() ?: "training", + statusNote = so["statusNote"]?.jsonPrimitive?.contentOrNull(), ) } @@ -639,26 +742,7 @@ class GymRepository( } }.getOrDefault(emptyMap()) - val pbs: List = runCatching { - val pb = client.execute( - "query{personalBests{exerciseTypeName repRecords{reps weight date}}}", - token = token, - ) - pb["personalBests"]!!.jsonArray.map { p -> - val po = p.jsonObject - PbEntry( - exerciseName = po["exerciseTypeName"]?.jsonPrimitive?.contentOrNull() ?: "Övning", - records = po["repRecords"]!!.jsonArray.mapNotNull { r -> - val ro = r.jsonObject - PbRecord( - reps = ro["reps"]?.jsonPrimitive?.intOrNull ?: return@mapNotNull null, - weight = ro["weight"]?.jsonPrimitive?.doubleOrNull ?: return@mapNotNull null, - date = ro["date"]?.jsonPrimitive?.contentOrNull()?.take(10) ?: "", - ) - }.sortedBy { it.reps }, - ) - }.sortedBy { it.exerciseName } - }.getOrDefault(emptyList()) + val pbs: List = runCatching { personalBestsDetailed("all") }.getOrDefault(emptyList()) fun kpi(key: String) = s[key]?.jsonPrimitive?.doubleOrNull ?: 0.0 fun prevKpi(key: String) = prev?.get(key)?.jsonPrimitive?.doubleOrNull @@ -786,6 +870,11 @@ data class HistorySet( val durationSeconds: Int?, val rpe: Double?, val isWarmup: Boolean, + val id: Int = 0, + val notes: String? = null, + /** Rekordstatus: training / competition / excluded (se LiftStatusSheet). */ + val status: String = "training", + val statusNote: String? = null, ) data class SessionDetail( @@ -801,6 +890,7 @@ data class SessionDetail( data class SessionDetailExercise( val name: String, val sets: List, + val exerciseTypeId: Int = 0, ) data class ExerciseHistoryEntry( @@ -843,8 +933,36 @@ data class BodyMeasurement( ) 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) +data class PbRecord( + val reps: Int, + val weight: Double, + val date: String, + val liftId: Int = 0, + val status: String = "training", + val statusNote: String? = null, + val notes: String? = null, + val sessionName: String? = null, + val est1Rm: Double = 0.0, +) +data class PbEntry(val exerciseName: String, val records: List, val exerciseTypeId: Int = 0) + +/** Ett lyft med allt PB-arket behöver (pbCandidates / liftDetail). */ +data class LiftDetail( + val id: Int, + val exerciseTypeId: Int, + val exerciseName: String, + val weight: Double, + val reps: Int, + val date: String, + val notes: String?, + val status: String, + val statusNote: String?, + val sessionId: Int?, + val sessionName: String?, + val setId: Int?, + val est1Rm: Double, + val isCurrentRecord: Boolean, +) data class StatsBundle( val sessionCount: Int, diff --git a/app/src/main/java/eu/brassepc/fitnessdroid/ui/common/LiftStatusSheet.kt b/app/src/main/java/eu/brassepc/fitnessdroid/ui/common/LiftStatusSheet.kt new file mode 100644 index 0000000..0106e1c --- /dev/null +++ b/app/src/main/java/eu/brassepc/fitnessdroid/ui/common/LiftStatusSheet.kt @@ -0,0 +1,280 @@ +package eu.brassepc.fitnessdroid.ui.common + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +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.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +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.LaunchedEffect +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.unit.dp +import eu.brassepc.fitnessdroid.data.GymRepository +import eu.brassepc.fitnessdroid.data.LiftDetail +import kotlinx.coroutines.launch + +/** + * Rekordstatus på ett lyft: träning (standard), tävling eller räknas ej, med + * kommentar. Öppnas från PB-listan (Statistik), passhistoriken och det aktiva + * passet. Visar detaljer om lyftet och en topplista ("näst i tur") för samma + * övning × reps där man kan välja ett annat lyft att ändra. + */ +sealed interface LiftStatusTarget { + val exerciseTypeId: Int + val exerciseName: String + val reps: Int? + + data class Lift( + override val exerciseTypeId: Int, + override val exerciseName: String, + override val reps: Int, + val liftId: Int, + ) : LiftStatusTarget + + data class Set( + val setId: Int, + override val exerciseTypeId: Int, + override val exerciseName: String, + override val reps: Int?, + val summary: String, + val status: String, + val statusNote: String?, + ) : LiftStatusTarget +} + +val LIFT_STATUS_OPTIONS = listOf( + "training" to "Träning", + "competition" to "Tävling", + "excluded" to "Räknas ej", +) + +fun liftStatusLabel(status: String): String = + LIFT_STATUS_OPTIONS.firstOrNull { it.first == status }?.second ?: status + +fun liftStatusIcon(status: String): String = when (status) { + "competition" -> "🎖️" + "excluded" -> "⛔" + else -> "" +} + +private fun statusHelp(status: String) = when (status) { + "competition" -> "Tävlingslyft. Får egen rekordlista och räknas även bland alla giltiga." + "excluded" -> "Fusk, felregistrerat eller ej godkänt. Tas ur rekord, 1RM och PB-höjdpunkter – nästa lyft i topplistan blir rekord." + else -> "Vanligt träningslyft. Räknas i alla rekordlistor utom Tävling." +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun LiftStatusSheet( + target: LiftStatusTarget, + repo: GymRepository, + mode: String = "all", + onDismiss: () -> Unit, + onChanged: () -> Unit, +) { + val scope = rememberCoroutineScope() + var loading by remember { mutableStateOf(true) } + var saving by remember { mutableStateOf(false) } + var error by remember { mutableStateOf(null) } + var candidates by remember { mutableStateOf>(emptyList()) } + var focus by remember { mutableStateOf(null) } + var status by remember { mutableStateOf((target as? LiftStatusTarget.Set)?.status ?: "training") } + var note by remember { mutableStateOf((target as? LiftStatusTarget.Set)?.statusNote ?: "") } + + suspend fun load(keepId: Int? = null) { + loading = true + error = null + try { + val reps = target.reps + candidates = if (reps != null) repo.pbCandidates(target.exerciseTypeId, reps, mode) else emptyList() + focus = when (target) { + is LiftStatusTarget.Lift -> + candidates.firstOrNull { it.id == (keepId ?: target.liftId) } ?: repo.liftDetail(target.liftId) + is LiftStatusTarget.Set -> + candidates.firstOrNull { if (keepId != null) it.id == keepId else it.setId == target.setId } + } + val cur = focus + status = cur?.status ?: (target as? LiftStatusTarget.Set)?.status ?: "training" + note = cur?.statusNote ?: (target as? LiftStatusTarget.Set)?.statusNote ?: "" + } catch (e: Exception) { + error = "Kunde inte hämta lyftet — offline?" + } finally { + loading = false + } + } + + LaunchedEffect(target) { load() } + + val current = focus + val dirty = status != (current?.status ?: (target as? LiftStatusTarget.Set)?.status ?: "training") || + note.trim() != (current?.statusNote ?: (target as? LiftStatusTarget.Set)?.statusNote ?: "") + + ModalBottomSheet(onDismissRequest = onDismiss) { + Column( + modifier = Modifier.padding(horizontal = 20.dp).padding(bottom = 32.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + val repsLabel = (current?.reps ?: target.reps)?.let { " · $it rep${if (it > 1) "s" else ""}" } ?: "" + Text("🏆 ${target.exerciseName}$repsLabel", style = MaterialTheme.typography.titleLarge) + + if (loading) { + Box(modifier = Modifier.fillMaxWidth().padding(24.dp), contentAlignment = Alignment.Center) { + CircularProgressIndicator() + } + } else { + error?.let { Text(it, color = MaterialTheme.colorScheme.error) } + + // ── Fokuserat lyft / set ── + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant), + ) { + Column(modifier = Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) { + if (current != null) { + Row(verticalAlignment = Alignment.Bottom) { + Text( + "${current.weight.compact()} kg × ${current.reps}", + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.weight(1f), + ) + if (current.est1Rm > 0) { + Text("est. 1RM ${current.est1Rm.compact()} kg", style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + } + Text( + buildString { + append(current.date) + current.sessionName?.let { append(" · pass \"$it\"") } ?: append(" · löst lyft") + if (current.isCurrentRecord) append(" · 🏆 gällande rekord") + }, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + current.notes?.takeIf { it.isNotBlank() }?.let { + Text("📝 $it", style = MaterialTheme.typography.bodySmall) + } + } else if (target is LiftStatusTarget.Set) { + Text(target.summary, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.primary) + Text( + "Statusen sätts på setet och följer med lyftet när passet avslutas.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + + // ── Status ── + Text("Status", style = MaterialTheme.typography.labelLarge) + SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) { + LIFT_STATUS_OPTIONS.forEachIndexed { i, (key, label) -> + SegmentedButton( + selected = status == key, + onClick = { status = key }, + shape = SegmentedButtonDefaults.itemShape(index = i, count = LIFT_STATUS_OPTIONS.size), + ) { Text(label) } + } + } + Text(statusHelp(status), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + OutlinedTextField( + value = note, + onValueChange = { if (it.length <= 300) note = it }, + label = { Text("Kommentar (valfri)") }, + placeholder = { Text(if (status == "excluded") "T.ex. ej godkänt djup, felregistrerat…" else "T.ex. SM 2026, PB-försök…") }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + Button( + onClick = { + scope.launch { + saving = true + try { + val n = note.trim().ifBlank { null } + val f = focus + if (f != null) repo.setLiftStatus(f.id, status, n) + else if (target is LiftStatusTarget.Set) repo.setSessionSetStatus(target.setId, status, n) + onChanged() + load(keepId = f?.id) + } catch (e: Exception) { + error = "Kunde inte spara — offline?" + } finally { + saving = false + } + } + }, + enabled = dirty && !saving, + modifier = Modifier.fillMaxWidth(), + ) { Text(if (saving) "Sparar…" else "Spara") } + + // ── Topplista ── + if (candidates.isNotEmpty()) { + Text( + "Topplista · ${target.reps} reps — tryck för att välja ett annat lyft", + style = MaterialTheme.typography.labelLarge, + modifier = Modifier.padding(top = 6.dp), + ) + candidates.forEach { c -> + val selected = c.id == current?.id + Card( + modifier = Modifier.fillMaxWidth().clickable { + focus = c + status = c.status + note = c.statusNote ?: "" + }, + colors = CardDefaults.cardColors( + containerColor = if (selected) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surface, + ), + ) { + Row( + modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + Text( + "${c.weight.compact()} kg", + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Bold, + color = if (c.status == "excluded") MaterialTheme.colorScheme.onSurfaceVariant else MaterialTheme.colorScheme.primary, + ) + Text(c.date, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + Text( + buildString { + if (c.isCurrentRecord) append("🏆 ") + append(liftStatusIcon(c.status)).append(" ").append(liftStatusLabel(c.status)) + c.statusNote?.let { append(" · $it") } + }.trim(), + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.weight(1f), + maxLines = 1, + ) + } + } + } + } + } + } + } +} diff --git a/app/src/main/java/eu/brassepc/fitnessdroid/ui/history/HistoryDetailScreen.kt b/app/src/main/java/eu/brassepc/fitnessdroid/ui/history/HistoryDetailScreen.kt index bef99e7..1a93138 100644 --- a/app/src/main/java/eu/brassepc/fitnessdroid/ui/history/HistoryDetailScreen.kt +++ b/app/src/main/java/eu/brassepc/fitnessdroid/ui/history/HistoryDetailScreen.kt @@ -11,6 +11,7 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.foundation.clickable import androidx.compose.material3.Card import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api @@ -43,7 +44,11 @@ import eu.brassepc.fitnessdroid.data.SessionStats import eu.brassepc.fitnessdroid.ui.appContainer import eu.brassepc.fitnessdroid.ui.common.CalorieInfoDialog import eu.brassepc.fitnessdroid.ui.common.CalorieRow +import eu.brassepc.fitnessdroid.ui.common.LiftStatusSheet +import eu.brassepc.fitnessdroid.ui.common.LiftStatusTarget import eu.brassepc.fitnessdroid.ui.common.describe +import eu.brassepc.fitnessdroid.ui.common.liftStatusIcon +import eu.brassepc.fitnessdroid.ui.common.liftStatusLabel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.launch @@ -57,10 +62,17 @@ sealed interface DetailUiState { ) : DetailUiState } -class HistoryDetailViewModel(private val repo: GymRepository) : ViewModel() { +class HistoryDetailViewModel(val repo: GymRepository) : ViewModel() { val uiState = MutableStateFlow(DetailUiState.Loading) private var loadedId: Int? = null + /** Hämta om passet (efter t.ex. ändrad rekordstatus). */ + fun reload() { + val id = loadedId ?: return + loadedId = null + load(id) + } + fun load(id: Int) { if (loadedId == id && uiState.value is DetailUiState.Ready) return loadedId = id @@ -128,6 +140,16 @@ fun HistoryDetailScreen( val d = state.detail val stats = state.stats var showCalorieInfo by remember { mutableStateOf(false) } + var statusSheet by remember { mutableStateOf(null) } + + statusSheet?.let { target -> + LiftStatusSheet( + target = target, + repo = viewModel.repo, + onDismiss = { statusSheet = null }, + onChanged = { viewModel.reload() }, + ) + } if (showCalorieInfo && stats != null) { CalorieInfoDialog( @@ -215,7 +237,23 @@ fun HistoryDetailScreen( } } exercise.sets.forEach { set -> - Row { + // Tryck på ett set för rekordstatus (träning/tävling/räknas ej). + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(enabled = set.id > 0) { + statusSheet = LiftStatusTarget.Set( + setId = set.id, + exerciseTypeId = exercise.exerciseTypeId, + exerciseName = exercise.name, + reps = set.reps, + summary = set.describe(), + status = set.status, + statusNote = set.statusNote, + ) + } + .padding(vertical = 2.dp), + ) { Text( "Set ${set.order}", style = MaterialTheme.typography.bodySmall, @@ -225,7 +263,15 @@ fun HistoryDetailScreen( Text( set.describe(), style = MaterialTheme.typography.bodySmall, + modifier = Modifier.weight(1f), ) + if (set.status != "training") { + Text( + "${liftStatusIcon(set.status)} ${liftStatusLabel(set.status)}", + style = MaterialTheme.typography.labelSmall, + color = if (set.status == "excluded") MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.tertiary, + ) + } } } if (exercise.sets.isEmpty()) { 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 c2c5e56..2a68a54 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 @@ -1,6 +1,8 @@ package eu.brassepc.fitnessdroid.ui.stats import androidx.compose.foundation.background +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.rememberScrollState import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Favorite import androidx.compose.foundation.layout.Arrangement @@ -39,12 +41,16 @@ import androidx.lifecycle.viewmodel.viewModelFactory import eu.brassepc.fitnessdroid.data.GymRepository import eu.brassepc.fitnessdroid.data.StatsBundle import eu.brassepc.fitnessdroid.ui.appContainer +import eu.brassepc.fitnessdroid.ui.common.LiftStatusSheet +import eu.brassepc.fitnessdroid.ui.common.LiftStatusTarget import eu.brassepc.fitnessdroid.ui.common.compact import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.launch data class PeriodOption(val id: String, val label: String) +val PB_MODES = listOf("all" to "Alla giltiga", "competition" to "Tävling", "training" to "Träning") + val PERIODS = listOf( PeriodOption("week", "Vecka"), PeriodOption("month", "Månad"), @@ -60,7 +66,7 @@ sealed interface StatsUiState { } class StatsViewModel( - private val repo: GymRepository, + val repo: GymRepository, private val gymApi: eu.brassepc.fitnessdroid.data.GymApi, ) : ViewModel() { val period = MutableStateFlow("month") @@ -120,6 +126,23 @@ class StatsViewModel( StatsUiState.Error("Kunde inte hämta statistiken — offline?") } } + refreshPbs() + } + + // ── Personbästa: läge (alla giltiga / tävling / träning) + lista ── + val pbMode = MutableStateFlow("all") + val pbs = MutableStateFlow>(emptyList()) + + fun setPbMode(mode: String) { + if (pbMode.value == mode) return + pbMode.value = mode + refreshPbs() + } + + fun refreshPbs() { + viewModelScope.launch { + runCatching { pbs.value = repo.personalBestsDetailed(pbMode.value) } + } } companion object { @@ -136,6 +159,19 @@ class StatsViewModel( fun StatsScreen(viewModel: StatsViewModel = viewModel(factory = StatsViewModel.Factory)) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() val period by viewModel.period.collectAsStateWithLifecycle() + val pbMode by viewModel.pbMode.collectAsStateWithLifecycle() + val pbs by viewModel.pbs.collectAsStateWithLifecycle() + var pbSheet by remember { mutableStateOf(null) } + + pbSheet?.let { target -> + LiftStatusSheet( + target = target, + repo = viewModel.repo, + mode = pbMode, + onDismiss = { pbSheet = null }, + onChanged = { viewModel.refreshPbs() }, + ) + } // Hämta om mätningarna varje gång fliken öppnas — ny vikt kan ha // lagts in via profilen sedan sist. @@ -343,25 +379,60 @@ fun StatsScreen(viewModel: StatsViewModel = viewModel(factory = StatsViewModel.F } } - if (stats.personalBests.isNotEmpty()) { + item { + Row( + modifier = Modifier.fillMaxWidth().padding(top = 6.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text("Personbästa", style = MaterialTheme.typography.titleMedium, modifier = Modifier.weight(1f)) + } + androidx.compose.foundation.lazy.LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.padding(top = 6.dp)) { + items(PB_MODES, key = { it.first }) { (key, label) -> + FilterChip( + selected = pbMode == key, + onClick = { viewModel.setPbMode(key) }, + label = { Text(label) }, + ) + } + } + } + if (pbs.isEmpty()) { item { Text( - "Personbästa", - style = MaterialTheme.typography.titleMedium, - modifier = Modifier.padding(top = 6.dp), + if (pbMode == "competition") "Inga tävlingslyft märkta än. Tryck på ett rekord eller ett set i historiken och välj Tävling." + else "Inga rekord än.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, ) } - items(stats.personalBests, key = { it.exerciseName }) { pb -> - Card(modifier = Modifier.fillMaxWidth()) { - Column(modifier = Modifier.padding(14.dp)) { - Text(pb.exerciseName, style = MaterialTheme.typography.titleSmall) - Text( - pb.records.joinToString(" ") { "${it.reps}RM ${it.weight.compact()}kg" }, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(top = 4.dp), - ) + } + items(pbs, key = { "${it.exerciseTypeId}-${it.exerciseName}" }) { pb -> + Card(modifier = Modifier.fillMaxWidth()) { + Column(modifier = Modifier.padding(14.dp)) { + Text(pb.exerciseName, style = MaterialTheme.typography.titleSmall) + Row( + modifier = Modifier + .padding(top = 6.dp) + .horizontalScroll(rememberScrollState()), + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + pb.records.forEach { r -> + androidx.compose.material3.AssistChip( + onClick = { + pbSheet = LiftStatusTarget.Lift(pb.exerciseTypeId, pb.exerciseName, r.reps, r.liftId) + }, + label = { + Text("${r.reps}RM ${r.weight.compact()} kg${if (r.status == "competition") " 🎖️" else ""}") + }, + ) + } } + Text( + "Tryck på ett rekord för detaljer, tävling eller räknas ej.", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 4.dp), + ) } } }