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å
|
# 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.
|
# 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) —
|
# 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
|
# Googles aapt2 finns bara för linux x86_64. Resten av Android-bygget är
|
||||||
# JVM-baserat och arkitekturoberoende.
|
# JVM-baserat och arkitekturoberoende.
|
||||||
@@ -22,6 +23,17 @@ jobs:
|
|||||||
build-release:
|
build-release:
|
||||||
runs-on: linux-amd64
|
runs-on: linux-amd64
|
||||||
steps:
|
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
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
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
|
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`).
|
"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
|
## Signeringsnyckeln
|
||||||
|
|
||||||
Nyckeln är **inte** incheckad (repot är publikt). Lokalt ligger den i
|
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
|
* 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.
|
* 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(
|
class GymRepository(
|
||||||
private val db: AppDatabase,
|
private val db: AppDatabase,
|
||||||
private val client: GraphQlClient,
|
private val client: GraphQlClient,
|
||||||
@@ -265,6 +268,123 @@ class GymRepository(
|
|||||||
suspend fun personalBests(): Map<Pair<Int, Int>, Double> =
|
suspend fun personalBests(): Map<Pair<Int, Int>, Double> =
|
||||||
db.cacheDao().personalBests().associate { (it.exerciseTypeId to it.reps) to it.weight }
|
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 ---------- */
|
/* ---------- Referensdata / cache ---------- */
|
||||||
|
|
||||||
/** Hämta om all referensdata. Tyst vid nätfel — cachen gäller tills vidare. */
|
/** Hämta om all referensdata. Tyst vid nätfel — cachen gäller tills vidare. */
|
||||||
@@ -314,29 +434,7 @@ class GymRepository(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
runCatching {
|
runCatching { refreshPbCache(token) }
|
||||||
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 {
|
runCatching {
|
||||||
val prof = client.execute("query{myProfile{bodyWeightKg displayName}}", token = token)
|
val prof = client.execute("query{myProfile{bodyWeightKg displayName}}", token = token)
|
||||||
@@ -520,7 +618,7 @@ class GymRepository(
|
|||||||
/** Full detalj för ett avslutat pass. */
|
/** Full detalj för ett avslutat pass. */
|
||||||
suspend fun fetchSessionDetail(id: Int): SessionDetail {
|
suspend fun fetchSessionDetail(id: Int): SessionDetail {
|
||||||
val data = client.execute(
|
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) },
|
buildJsonObject { put("id", id) },
|
||||||
auth.bearerToken(),
|
auth.bearerToken(),
|
||||||
)
|
)
|
||||||
@@ -546,6 +644,7 @@ class GymRepository(
|
|||||||
?: "Övning",
|
?: "Övning",
|
||||||
sets = eo["sets"]!!.jsonArray.map(::parseHistorySet)
|
sets = eo["sets"]!!.jsonArray.map(::parseHistorySet)
|
||||||
.sortedBy { it.order },
|
.sortedBy { it.order },
|
||||||
|
exerciseTypeId = eo["exerciseType"]?.jsonObject?.get("id")?.jsonPrimitive?.intOrNull ?: 0,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -603,6 +702,10 @@ class GymRepository(
|
|||||||
durationSeconds = so["durationSeconds"]?.jsonPrimitive?.intOrNull,
|
durationSeconds = so["durationSeconds"]?.jsonPrimitive?.intOrNull,
|
||||||
rpe = so["rpe"]?.jsonPrimitive?.doubleOrNull,
|
rpe = so["rpe"]?.jsonPrimitive?.doubleOrNull,
|
||||||
isWarmup = so["isWarmup"]?.jsonPrimitive?.booleanOrNull ?: false,
|
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())
|
}.getOrDefault(emptyMap())
|
||||||
|
|
||||||
val pbs: List<PbEntry> = runCatching {
|
val pbs: List<PbEntry> = runCatching { personalBestsDetailed("all") }.getOrDefault(emptyList())
|
||||||
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())
|
|
||||||
|
|
||||||
fun kpi(key: String) = s[key]?.jsonPrimitive?.doubleOrNull ?: 0.0
|
fun kpi(key: String) = s[key]?.jsonPrimitive?.doubleOrNull ?: 0.0
|
||||||
fun prevKpi(key: String) = prev?.get(key)?.jsonPrimitive?.doubleOrNull
|
fun prevKpi(key: String) = prev?.get(key)?.jsonPrimitive?.doubleOrNull
|
||||||
@@ -786,6 +870,11 @@ data class HistorySet(
|
|||||||
val durationSeconds: Int?,
|
val durationSeconds: Int?,
|
||||||
val rpe: Double?,
|
val rpe: Double?,
|
||||||
val isWarmup: Boolean,
|
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(
|
data class SessionDetail(
|
||||||
@@ -801,6 +890,7 @@ data class SessionDetail(
|
|||||||
data class SessionDetailExercise(
|
data class SessionDetailExercise(
|
||||||
val name: String,
|
val name: String,
|
||||||
val sets: List<HistorySet>,
|
val sets: List<HistorySet>,
|
||||||
|
val exerciseTypeId: Int = 0,
|
||||||
)
|
)
|
||||||
|
|
||||||
data class ExerciseHistoryEntry(
|
data class ExerciseHistoryEntry(
|
||||||
@@ -843,8 +933,36 @@ data class BodyMeasurement(
|
|||||||
)
|
)
|
||||||
|
|
||||||
data class TrendBucket(val label: String, val volumeKg: Double, val sessionCount: Int)
|
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 PbRecord(
|
||||||
data class PbEntry(val exerciseName: String, val records: List<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(
|
data class StatsBundle(
|
||||||
val sessionCount: Int,
|
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.foundation.lazy.items
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.material3.Card
|
import androidx.compose.material3.Card
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
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.appContainer
|
||||||
import eu.brassepc.fitnessdroid.ui.common.CalorieInfoDialog
|
import eu.brassepc.fitnessdroid.ui.common.CalorieInfoDialog
|
||||||
import eu.brassepc.fitnessdroid.ui.common.CalorieRow
|
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.describe
|
||||||
|
import eu.brassepc.fitnessdroid.ui.common.liftStatusIcon
|
||||||
|
import eu.brassepc.fitnessdroid.ui.common.liftStatusLabel
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
@@ -57,10 +62,17 @@ sealed interface DetailUiState {
|
|||||||
) : DetailUiState
|
) : DetailUiState
|
||||||
}
|
}
|
||||||
|
|
||||||
class HistoryDetailViewModel(private val repo: GymRepository) : ViewModel() {
|
class HistoryDetailViewModel(val repo: GymRepository) : ViewModel() {
|
||||||
val uiState = MutableStateFlow<DetailUiState>(DetailUiState.Loading)
|
val uiState = MutableStateFlow<DetailUiState>(DetailUiState.Loading)
|
||||||
private var loadedId: Int? = null
|
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) {
|
fun load(id: Int) {
|
||||||
if (loadedId == id && uiState.value is DetailUiState.Ready) return
|
if (loadedId == id && uiState.value is DetailUiState.Ready) return
|
||||||
loadedId = id
|
loadedId = id
|
||||||
@@ -128,6 +140,16 @@ fun HistoryDetailScreen(
|
|||||||
val d = state.detail
|
val d = state.detail
|
||||||
val stats = state.stats
|
val stats = state.stats
|
||||||
var showCalorieInfo by remember { mutableStateOf(false) }
|
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) {
|
if (showCalorieInfo && stats != null) {
|
||||||
CalorieInfoDialog(
|
CalorieInfoDialog(
|
||||||
@@ -215,7 +237,23 @@ fun HistoryDetailScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
exercise.sets.forEach { set ->
|
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(
|
Text(
|
||||||
"Set ${set.order}",
|
"Set ${set.order}",
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
@@ -225,7 +263,15 @@ fun HistoryDetailScreen(
|
|||||||
Text(
|
Text(
|
||||||
set.describe(),
|
set.describe(),
|
||||||
style = MaterialTheme.typography.bodySmall,
|
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()) {
|
if (exercise.sets.isEmpty()) {
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
package eu.brassepc.fitnessdroid.ui.stats
|
package eu.brassepc.fitnessdroid.ui.stats
|
||||||
|
|
||||||
import androidx.compose.foundation.background
|
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.Icons
|
||||||
import androidx.compose.material.icons.filled.Favorite
|
import androidx.compose.material.icons.filled.Favorite
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
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.GymRepository
|
||||||
import eu.brassepc.fitnessdroid.data.StatsBundle
|
import eu.brassepc.fitnessdroid.data.StatsBundle
|
||||||
import eu.brassepc.fitnessdroid.ui.appContainer
|
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 eu.brassepc.fitnessdroid.ui.common.compact
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
data class PeriodOption(val id: String, val label: String)
|
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(
|
val PERIODS = listOf(
|
||||||
PeriodOption("week", "Vecka"),
|
PeriodOption("week", "Vecka"),
|
||||||
PeriodOption("month", "Månad"),
|
PeriodOption("month", "Månad"),
|
||||||
@@ -60,7 +66,7 @@ sealed interface StatsUiState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class StatsViewModel(
|
class StatsViewModel(
|
||||||
private val repo: GymRepository,
|
val repo: GymRepository,
|
||||||
private val gymApi: eu.brassepc.fitnessdroid.data.GymApi,
|
private val gymApi: eu.brassepc.fitnessdroid.data.GymApi,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
val period = MutableStateFlow("month")
|
val period = MutableStateFlow("month")
|
||||||
@@ -120,6 +126,23 @@ class StatsViewModel(
|
|||||||
StatsUiState.Error("Kunde inte hämta statistiken — offline?")
|
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 {
|
companion object {
|
||||||
@@ -136,6 +159,19 @@ class StatsViewModel(
|
|||||||
fun StatsScreen(viewModel: StatsViewModel = viewModel(factory = StatsViewModel.Factory)) {
|
fun StatsScreen(viewModel: StatsViewModel = viewModel(factory = StatsViewModel.Factory)) {
|
||||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||||
val period by viewModel.period.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
|
// Hämta om mätningarna varje gång fliken öppnas — ny vikt kan ha
|
||||||
// lagts in via profilen sedan sist.
|
// lagts in via profilen sedan sist.
|
||||||
@@ -343,21 +379,57 @@ fun StatsScreen(viewModel: StatsViewModel = viewModel(factory = StatsViewModel.F
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (stats.personalBests.isNotEmpty()) {
|
|
||||||
item {
|
item {
|
||||||
Text(
|
Row(
|
||||||
"Personbästa",
|
modifier = Modifier.fillMaxWidth().padding(top = 6.dp),
|
||||||
style = MaterialTheme.typography.titleMedium,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
modifier = Modifier.padding(top = 6.dp),
|
) {
|
||||||
|
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) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
items(stats.personalBests, key = { it.exerciseName }) { pb ->
|
}
|
||||||
|
}
|
||||||
|
if (pbs.isEmpty()) {
|
||||||
|
item {
|
||||||
|
Text(
|
||||||
|
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(pbs, key = { "${it.exerciseTypeId}-${it.exerciseName}" }) { pb ->
|
||||||
Card(modifier = Modifier.fillMaxWidth()) {
|
Card(modifier = Modifier.fillMaxWidth()) {
|
||||||
Column(modifier = Modifier.padding(14.dp)) {
|
Column(modifier = Modifier.padding(14.dp)) {
|
||||||
Text(pb.exerciseName, style = MaterialTheme.typography.titleSmall)
|
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(
|
Text(
|
||||||
pb.records.joinToString(" ") { "${it.reps}RM ${it.weight.compact()}kg" },
|
"Tryck på ett rekord för detaljer, tävling eller räknas ej.",
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.labelSmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
modifier = Modifier.padding(top = 4.dp),
|
modifier = Modifier.padding(top = 4.dp),
|
||||||
)
|
)
|
||||||
@@ -367,7 +439,6 @@ fun StatsScreen(viewModel: StatsViewModel = viewModel(factory = StatsViewModel.F
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private data class CompPart(val label: String, val value: (eu.brassepc.fitnessdroid.data.BodyMeasurement) -> Double?)
|
private data class CompPart(val label: String, val value: (eu.brassepc.fitnessdroid.data.BodyMeasurement) -> Double?)
|
||||||
|
|||||||
Reference in New Issue
Block a user