Compare commits
2 Commits
d2a37f21de
...
923f58420d
| Author | SHA1 | Date | |
|---|---|---|---|
| 923f58420d | |||
| 139a4c20ef |
@@ -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
|
||||
|
||||
|
||||
10
README.md
10
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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user