Compare commits

...

2 Commits

Author SHA1 Message Date
923f58420d CI: vägra bygga APK på arm64/Pi5 — spärr först i jobbet
Some checks failed
release / build-release (push) Failing after 13m22s
Bygget ska bara gå på brasse-linux01 (label linux-amd64). Om jobbet ändå
hamnar på en arm64-runner avbryts det direkt, innan gradle startar, så att
Pi5:n inte OOM:ar och tar ner alla tjänster.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AFvSx1pF9pymyEuK7MFkEG
2026-09-06 12:57:42 +02:00
139a4c20ef Rekordstatus: träning/tävling/räknas ej på lyft + PB-lägen och detaljark
- 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AFvSx1pF9pymyEuK7MFkEG
2026-09-06 09:16:02 +02:00
6 changed files with 601 additions and 64 deletions

View File

@@ -5,7 +5,8 @@ name: release
#
# Byggs ALLTID på brasse-linux01 (label linux-amd64) — Pi5:n OOM:ar på
# APK-bygget, och Gitea saknar runner-prioritet så delade labels gav slumpen.
# Är huvuddatorn av köas jobbet tills runnern är tillbaka.
# Är huvuddatorn av köas jobbet tills runnern är tillbaka. Första steget
# vägrar dessutom köra på arm64 (2026-09-06), som skydd om labels ändras.
# På arm64 används en statiskt byggd aapt2 (lzhiyong/android-sdk-tools) —
# Googles aapt2 finns bara för linux x86_64. Resten av Android-bygget är
# JVM-baserat och arkitekturoberoende.
@@ -22,6 +23,17 @@ jobs:
build-release:
runs-on: linux-amd64
steps:
# Säkerhetsspärr: APK-bygget får ALDRIG köra på Pi5:n (OOM ⇒ hela servern
# nere). Om jobbet ändå hamnar på en arm64-runner (t.ex. om Pi-runnern
# fått labeln linux-amd64) avbryts det här, innan något tungt startat.
- name: Vägra bygga på arm64/Pi5
run: |
if [ "$(uname -m)" != "x86_64" ]; then
echo "::error::Fel runner ($(uname -m), $(hostname)). APK-bygget ska köras på brasse-linux01 (label linux-amd64). Avbryter för att inte ta ner Pi5:n."
exit 1
fi
echo "Runner OK: $(uname -m) på $(hostname)"
- name: Checkout
uses: actions/checkout@v4

View File

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

View File

@@ -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<Pair<Int, Int>, 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<PbEntry> {
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<LiftDetail> {
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<PbEntry> = 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<PbEntry> = 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<HistorySet>,
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<PbRecord>)
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<PbRecord>, 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,

View File

@@ -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<String?>(null) }
var candidates by remember { mutableStateOf<List<LiftDetail>>(emptyList()) }
var focus by remember { mutableStateOf<LiftDetail?>(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,
)
}
}
}
}
}
}
}
}

View File

@@ -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>(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<LiftStatusTarget?>(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()) {

View File

@@ -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<List<eu.brassepc.fitnessdroid.data.PbEntry>>(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<LiftStatusTarget?>(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),
)
}
}
}