Compare commits

..

7 Commits

Author SHA1 Message Date
9a7b564e49 Grafik: km/h radbröts i aktivitetslistan, trenddiagrammet startade på tomma framtidsdagar, "1 reps"; tools/waydroid-ui.py
All checks were successful
release / build-release (push) Successful in 4m22s
Klickrunda i Waydroid (telefonlayout 411×891 dp) genom alla flikar, inställningar,
mål, aktiviteter, passdetalj, PB-sök, detaljark, våg och om-sidan.

- Aktiviteter: "km/h" bröts till "km/" + "h" när kcal-kolumnen trängde ihop
  raden – word joiner (U+2060) efter snedstrecket håller enheten ihop.
- Statistik → Volym per dag/vecka: LazyRow scrollade till sista stapeln, dvs.
  månadens tomma framtidsdagar. Nu: senaste stapel med volym, annars dagens
  stapel, annars längst till höger.
- LiftStatusSheet: "Topplista · 1 reps" → "1 rep".
- tools/waydroid-ui.py: adb-hjälpare (dump/tap/shot/swipe/text/key) för
  klickrundor i Waydroid; README beskriver flödet + wm size/density-tricket.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NGT3xpdm51McmSrJcHnrzq
2026-09-10 00:50:21 +02:00
213f635d93 Statistik: PB-listan laddade inte — init-ordning i StatsViewModel + fallback mot äldre gym-API
All checks were successful
release / build-release (push) Successful in 4m22s
- StatsViewModel: pbMode/pbs deklarerades efter init { load() } → refreshPbs()
  körde på null vid första öppningen (NPE som runCatching svalde). Listan kom
  först när man bytte läge/period. Deklarationerna flyttade före init.
- personalBestsDetailed: känner igen schemafel ("does not exist") från ett
  äldre API och faller tillbaka på den gamla personalBests-frågan +
  favoriteExercises; PbList.legacyApi styr UI:t (lägen/sök/ark avstängda,
  notis om att gym-API:t behöver uppdateras).
- Fel vid PB-hämtning visas under rubriken i stället för tom lista;
  LiftStatusSheet/PbSearchCard visar serverns fel i stället för "offline?".
- README: API-krav för rekordstatus, fallback, Waydroid-testflöde.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NGT3xpdm51McmSrJcHnrzq
2026-09-10 00:24:49 +02:00
6ce8853309 Statistik: Sök i rekorden — övning + vikt→reps eller reps→vikt, exakt/närmast, 1RM-uppskattning
All checks were successful
release / build-release (push) Successful in 4m21s
PbSearchCard.kt ovanför PB-listan, GymRepository.pbLookup. Träffar öppnar LiftStatusSheet.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AFvSx1pF9pymyEuK7MFkEG
2026-09-07 20:25:08 +02:00
8f7cd9a419 Statistik: favoritövningar överst i PB-listan (stjärna på korten, återanvänder toggleFavoriteExercise)
All checks were successful
release / build-release (push) Successful in 4m31s
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AFvSx1pF9pymyEuK7MFkEG
2026-09-06 22:13:09 +02:00
c2161869bb CI: kör om release-bygget (run 57 föll under länkproblemet på Pi:n)
All checks were successful
release / build-release (push) Successful in 4m31s
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AFvSx1pF9pymyEuK7MFkEG
2026-09-06 21:53:29 +02:00
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
10 changed files with 1109 additions and 67 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

@@ -41,11 +41,49 @@ I VS Code: kör build-tasken (`Ctrl+Skift+B`) — den lägger APK:n i
`build/FitnessDroid.apk`. Tasken `installera på telefon (adb)` installerar den
direkt på en ansluten telefon med USB-felsökning på.
### Testa lokalt i Waydroid
Waydroid (x86_64, Android 13) fungerar som emulator på brasse-linux01. Sessionen startas
med `waydroid session start` + `waydroid show-full-ui`, sedan
`adb connect 192.168.240.112:5555` (IP:n från `waydroid status`) — första gången måste
*Allow USB debugging* godkännas i Waydroid-fönstret. Installera med
`adb install -r app/build/outputs/apk/debug/app-debug.apk`; skärmdumpar via
`adb exec-out screencap -p > fil.png`, UI-koordinater via `uiautomator dump`.
Saknar Android nät bortom värden är det Dockers FORWARD-spärr — se infra-Doc
`hosts/brasse-linux01.md` (Waydroid-avsnittet). Testkonto: `bb01`.
Skalskriptet `tools/waydroid-ui.py` (kräver `pillow`) gör klickrundor enkla: `dump` listar
texter med koordinater, `tap "Statistik"` trycker på en text, `shot namn` sparar en
skärmdump av appfönstret i `/tmp/shots/`, `swipe up|down` scrollar. Waydroid-fönstret är
en bred surfplatta; för telefonlayout sätt `adb shell wm size 720x1560 && adb shell wm
density 280` (≈ 411×891 dp) och återställ med `wm size reset && wm density reset`.
### Testa mot API:t
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. Kortet **Sök i rekorden** överst i Personbästa svarar på "hur många reps på X kg?" eller
"hur tungt på N reps?" exakt träff eller närmast under/över, plus en uppskattning via
bästa est. 1RM. Stjärnan på ett PB-kort gör övningen till **favorit** (samma
favoriter som övningsväljaren) så att den ligger överst i PB-listan i både app och webb. "Räknas ej" tas ur rekord, 1RM och PB-höjdpunkter på servern
(kod: `ui/common/LiftStatusSheet.kt`, API: `setLiftStatus`/`setSessionSetStatus`).
**Kräver gym-API:t från 2026-09-06 eller senare** (`personalBests(mode)`, `pbLookup`,
`pbCandidates`, `liftDetail`, `setLiftStatus`). Mot ett äldre API faller appen tillbaka på
den gamla PB-frågan: rekorden visas fortfarande (favoriter hämtas via `favoriteExercises`),
men lägesväxeln, sökkortet och detaljarket är avstängda och en notis säger att servern
behöver uppdateras. Fel vid hämtningen visas under rubriken i stället för att listan tyst
blir tom. Schemafel känns igen på HotChocolates "does not exist" (`GraphQlException.isSchemaMismatch()`
i `data/GraphQlClient.kt`).
## Signeringsnyckeln
Nyckeln är **inte** incheckad (repot är publikt). Lokalt ligger den i

View File

@@ -17,6 +17,26 @@ import java.util.concurrent.TimeUnit
class GraphQlException(val messages: List<String>) : Exception(messages.joinToString("; "))
/**
* Sant när servern inte känner till ett fält eller argument i frågan — dvs. den
* kör ett äldre gym-API än appen är byggd för (HotChocolate svarar
* "The field `x` does not exist on the type `Y`." / "The argument `x` does not exist.").
* Nät- och auth-fel ger aldrig detta.
*/
fun GraphQlException.isSchemaMismatch(): Boolean =
messages.any { it.contains("does not exist") }
/**
* Feltext att visa användaren: pekar ut ett för gammalt gym-API på servern,
* annars serverns eget GraphQL-meddelande, och för allt annat (IO, timeout) [offline].
*/
fun Throwable.userMessage(offline: String): String = when {
this is GraphQlException && isSchemaMismatch() ->
"Servern kör ett äldre gym-API som saknar den här funktionen — uppdatera gym-API:t."
this is GraphQlException -> messages.joinToString("; ")
else -> offline
}
/**
* Minimal GraphQL-klient över OkHttp. Samma request-form som webbklienten
* (gymTrackerApi.js): POST {query, variables} + "Authorization: Bearer <jwt>".

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,196 @@ 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.
*
* Kör servern ett äldre gym-API (utan rekordstatus, favoriter i PB-svaret och
* pbLookup) svarar den med schemafel på den fullständiga frågan. Då hämtas i
* stället den gamla frågan (övning + reps/vikt/datum, favoriter via
* favoriteExercises) och [PbList.legacyApi] blir true så att UI:t kan visa
* rekorden men gömma det servern inte kan. Nät- och auth-fel kastas vidare.
*/
suspend fun personalBestsDetailed(mode: String = "all"): PbList {
val token = auth.bearerToken()
val full = try {
client.execute(
"query(\$m:String){personalBests(mode:\$m){exerciseTypeId exerciseTypeName isFavorite repRecords{reps weight date liftId notes status statusNote sessionName estimated1Rm}}}",
buildJsonObject { put("m", mode) },
token,
)
} catch (e: GraphQlException) {
if (!e.isSchemaMismatch()) throw e
null
}
if (full != null) return PbList(parsePbEntries(full), legacyApi = false)
val legacy = client.execute(
"query{personalBests{exerciseTypeId exerciseTypeName repRecords{reps weight date}}}",
token = token,
)
val favorites = runCatching {
client.execute("query{favoriteExercises{id}}", token = token)["favoriteExercises"]!!
.jsonArray.map { it.jsonObject["id"]!!.jsonPrimitive.int }.toSet()
}.getOrDefault(emptySet())
return PbList(parsePbEntries(legacy, favorites), legacyApi = true)
}
private fun parsePbEntries(
data: kotlinx.serialization.json.JsonObject,
favorites: Set<Int> = emptySet(),
): List<PbEntry> =
data["personalBests"]!!.jsonArray.map { p ->
val po = p.jsonObject
val typeId = po["exerciseTypeId"]?.jsonPrimitive?.intOrNull ?: 0
PbEntry(
exerciseName = po["exerciseTypeName"]?.jsonPrimitive?.contentOrNull() ?: "Övning",
exerciseTypeId = typeId,
isFavorite = po["isFavorite"]?.jsonPrimitive?.booleanOrNull ?: (typeId in favorites),
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 },
)
}.sortedWith(compareByDescending<PbEntry> { it.isFavorite }.thenBy { it.exerciseName })
/**
* PB-sökning: reps vid given vikt eller vikt vid givna reps exakt träff eller
* närmast under/över, plus hela profilen och en Epley-uppskattning.
*/
suspend fun pbLookup(exerciseTypeId: Int, weight: Double? = null, reps: Int? = null, mode: String = "all"): PbLookup {
val hit = "weight reps date liftId status statusNote sessionName estimated1Rm count"
val d = client.execute(
"query(\$e:Int!,\$w:Float,\$r:Int,\$m:String){pbLookup(exerciseTypeId:\$e,weight:\$w,reps:\$r,mode:\$m){exerciseTypeId exerciseTypeName mode queryWeight queryReps estimatedReps estimatedWeight bestEstimated1Rm exact{$hit} below{$hit} above{$hit} byWeight{$hit} byReps{$hit}}}",
buildJsonObject {
put("e", exerciseTypeId); weight?.let { put("w", it) }; reps?.let { put("r", it) }; put("m", mode)
},
auth.bearerToken(),
)
val o = d["pbLookup"]!!.jsonObject
fun hitOf(key: String): PbLookupHit? = o[key]?.takeIf { it !is kotlinx.serialization.json.JsonNull }?.jsonObject?.let(::parseLookupHit)
return PbLookup(
exerciseTypeId = o["exerciseTypeId"]?.jsonPrimitive?.intOrNull ?: exerciseTypeId,
exerciseName = o["exerciseTypeName"]?.jsonPrimitive?.contentOrNull() ?: "",
queryWeight = o["queryWeight"]?.jsonPrimitive?.doubleOrNull,
queryReps = o["queryReps"]?.jsonPrimitive?.intOrNull,
exact = hitOf("exact"),
below = hitOf("below"),
above = hitOf("above"),
estimatedReps = o["estimatedReps"]?.jsonPrimitive?.doubleOrNull,
estimatedWeight = o["estimatedWeight"]?.jsonPrimitive?.doubleOrNull,
bestEstimated1Rm = o["bestEstimated1Rm"]?.jsonPrimitive?.doubleOrNull,
byWeight = o["byWeight"]?.jsonArray?.map { parseLookupHit(it.jsonObject) }.orEmpty(),
byReps = o["byReps"]?.jsonArray?.map { parseLookupHit(it.jsonObject) }.orEmpty(),
)
}
private fun parseLookupHit(h: kotlinx.serialization.json.JsonObject) = PbLookupHit(
weight = h["weight"]?.jsonPrimitive?.doubleOrNull ?: 0.0,
reps = h["reps"]?.jsonPrimitive?.intOrNull ?: 0,
date = h["date"]?.jsonPrimitive?.contentOrNull()?.take(10) ?: "",
liftId = h["liftId"]?.jsonPrimitive?.intOrNull ?: 0,
status = h["status"]?.jsonPrimitive?.contentOrNull() ?: "training",
sessionName = h["sessionName"]?.jsonPrimitive?.contentOrNull(),
count = h["count"]?.jsonPrimitive?.intOrNull ?: 1,
)
/** Topplista för övning × reps, alla statusar (så bortplockade kan återställas). */
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 +507,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 +691,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 +717,7 @@ class GymRepository(
?: "Övning",
sets = eo["sets"]!!.jsonArray.map(::parseHistorySet)
.sortedBy { it.order },
exerciseTypeId = eo["exerciseType"]?.jsonObject?.get("id")?.jsonPrimitive?.intOrNull ?: 0,
)
},
)
@@ -603,6 +775,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 +815,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").entries }.getOrDefault(emptyList())
fun kpi(key: String) = s[key]?.jsonPrimitive?.doubleOrNull ?: 0.0
fun prevKpi(key: String) = prev?.get(key)?.jsonPrimitive?.doubleOrNull
@@ -786,6 +943,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 +963,7 @@ data class SessionDetail(
data class SessionDetailExercise(
val name: String,
val sets: List<HistorySet>,
val exerciseTypeId: Int = 0,
)
data class ExerciseHistoryEntry(
@@ -843,8 +1006,74 @@ 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,
/** Favoritövning (samma FavoriteExercise som övningsväljaren) ligger överst i PB-listan. */
val isFavorite: Boolean = false,
)
/**
* Svar från [GymRepository.personalBestsDetailed]. [legacyApi] = servern kör ett
* äldre gym-API: rekorden finns men utan lyft-id/status (liftId = 0), läget
* (mode) ignoreras och pbLookup/pbCandidates/liftDetail saknas.
*/
data class PbList(val entries: List<PbEntry>, val legacyApi: Boolean = false)
data class PbLookupHit(
val weight: Double,
val reps: Int,
val date: String,
val liftId: Int,
val status: String,
val sessionName: String?,
val count: Int,
)
data class PbLookup(
val exerciseTypeId: Int,
val exerciseName: String,
val queryWeight: Double?,
val queryReps: Int?,
val exact: PbLookupHit?,
val below: PbLookupHit?,
val above: PbLookupHit?,
val estimatedReps: Double?,
val estimatedWeight: Double?,
val bestEstimated1Rm: Double?,
val byWeight: List<PbLookupHit>,
val byReps: List<PbLookupHit>,
)
/** Ett lyft med allt PB-arket behöver (pbCandidates / liftDetail). */
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

@@ -435,7 +435,9 @@ private fun ActivityRow(a: Activity, onClick: () -> Unit, onShowMap: (() -> Unit
a.distanceMeters?.let {
append(" · ${(it / 1000).compact()} km")
val kmh = (it / 1000) / (a.durationSeconds / 3600.0)
append(" · ${kmh.compact()} km/h")
// U+2060 (word joiner) efter snedstrecket: annars radbryts "km/h"
// till "km/" + "h" när kcal-kolumnen tränger ihop raden.
append(" · ${kmh.compact()} km/\u2060h")
}
a.rpe?.let { append(" · RPE ${it.toInt()}") }
},

View File

@@ -0,0 +1,281 @@
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.userMessage
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 = e.userMessage("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 = e.userMessage("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} ${if (target.reps == 1) "rep" else "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

@@ -0,0 +1,189 @@
package eu.brassepc.fitnessdroid.ui.stats
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExposedDropdownMenuBox
import androidx.compose.material3.ExposedDropdownMenuDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.SegmentedButton
import androidx.compose.material3.SegmentedButtonDefaults
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import eu.brassepc.fitnessdroid.data.userMessage
import eu.brassepc.fitnessdroid.data.GymRepository
import eu.brassepc.fitnessdroid.data.PbEntry
import eu.brassepc.fitnessdroid.data.PbLookup
import eu.brassepc.fitnessdroid.data.PbLookupHit
import eu.brassepc.fitnessdroid.ui.common.LiftStatusTarget
import eu.brassepc.fitnessdroid.ui.common.compact
import kotlinx.coroutines.launch
/**
* "Sök i rekorden": välj övning och fråga antingen hur många reps du max gjort på
* en vikt, eller hur tungt du lyft på ett repsantal. Exakt träff, annars närmast
* under/över. Tryck på en träff öppnar LiftStatusSheet via [onOpenLift].
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun PbSearchCard(
exercises: List<PbEntry>,
mode: String,
repo: GymRepository,
onOpenLift: (LiftStatusTarget) -> Unit,
) {
val scope = rememberCoroutineScope()
var expanded by remember { mutableStateOf(false) }
var selected by remember { mutableStateOf<PbEntry?>(null) }
var byWeight by remember { mutableStateOf(true) }
var value by remember { mutableStateOf("") }
var loading by remember { mutableStateOf(false) }
var error by remember { mutableStateOf<String?>(null) }
var result by remember { mutableStateOf<PbLookup?>(null) }
val number = value.replace(',', '.').toDoubleOrNull()
val canSearch = selected != null && number != null && number > 0
fun search() {
val ex = selected ?: return
val n = number ?: return
scope.launch {
loading = true
error = null
try {
result = if (byWeight) repo.pbLookup(ex.exerciseTypeId, weight = n, mode = mode)
else repo.pbLookup(ex.exerciseTypeId, reps = n.toInt(), mode = mode)
} catch (e: Exception) {
error = e.userMessage("Kunde inte söka — offline?")
} finally {
loading = false
}
}
}
Card(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
Text("Sök i rekorden", style = MaterialTheme.typography.titleSmall)
Text(
"Hur många reps på en vikt, eller hur tungt på ett repsantal? Utan exakt träff visas närmast under och över.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
ExposedDropdownMenuBox(expanded = expanded, onExpandedChange = { expanded = it }) {
OutlinedTextField(
value = selected?.let { (if (it.isFavorite) "" else "") + it.exerciseName } ?: "",
onValueChange = {},
readOnly = true,
label = { Text("Övning") },
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) },
modifier = Modifier.fillMaxWidth().menuAnchor(),
)
ExposedDropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
exercises.forEach { e ->
DropdownMenuItem(
text = { Text((if (e.isFavorite) "" else "") + e.exerciseName) },
onClick = { selected = e; expanded = false; result = null },
)
}
}
}
SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) {
SegmentedButton(selected = byWeight, onClick = { byWeight = true; result = null }, shape = SegmentedButtonDefaults.itemShape(0, 2)) { Text("Vikt → reps") }
SegmentedButton(selected = !byWeight, onClick = { byWeight = false; result = null }, shape = SegmentedButtonDefaults.itemShape(1, 2)) { Text("Reps → vikt") }
}
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) {
OutlinedTextField(
value = value,
onValueChange = { value = it },
label = { Text(if (byWeight) "Vikt (kg)" else "Antal reps") },
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
modifier = Modifier.weight(1f),
)
Button(onClick = { search() }, enabled = canSearch && !loading) { Text(if (loading) "Söker…" else "Sök") }
}
error?.let { Text(it, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall) }
result?.let { r ->
val q = if (r.queryWeight != null) "${r.queryWeight.compact()} kg" else "${r.queryReps} reps"
Text("${r.exerciseName} · $q", style = MaterialTheme.typography.labelLarge)
if (r.byWeight.isEmpty()) {
Text("Inga giltiga lyft på övningen i valt läge.", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
} else {
val exact = r.exact
if (exact != null) {
HitCard("🎯 Exakt träff", exact, byWeight, MaterialTheme.colorScheme.primaryContainer) { onOpenLift(target(r, exact)) }
} else {
Text("Inget lyft på exakt $q. Närmast:", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
r.below?.let { HitCard("▼ Under", it, byWeight, MaterialTheme.colorScheme.surfaceVariant) { onOpenLift(target(r, it)) } }
?: Text("▼ Under: inget lättare", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
r.above?.let { HitCard("▲ Över", it, byWeight, MaterialTheme.colorScheme.surfaceVariant) { onOpenLift(target(r, it)) } }
?: Text("▲ Över: inget tyngre", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
val est = r.bestEstimated1Rm?.let { rm ->
if (byWeight && r.estimatedReps != null && r.queryWeight != null) "📐 Uppskattat via bästa est. 1RM (${rm.compact()} kg): ~${r.estimatedReps.compact()} reps på ${r.queryWeight.compact()} kg."
else if (!byWeight && r.estimatedWeight != null) "📐 Uppskattat via bästa est. 1RM (${rm.compact()} kg): ~${r.estimatedWeight.compact()} kg på ${r.queryReps} reps."
else null
}
est?.let { Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) }
}
}
}
}
}
private fun target(r: PbLookup, h: PbLookupHit) =
LiftStatusTarget.Lift(exerciseTypeId = r.exerciseTypeId, exerciseName = r.exerciseName, reps = h.reps, liftId = h.liftId)
@Composable
private fun HitCard(label: String, h: PbLookupHit, byWeight: Boolean, container: androidx.compose.ui.graphics.Color, onClick: () -> Unit) {
Card(
modifier = Modifier.fillMaxWidth().clickable(onClick = onClick),
colors = CardDefaults.cardColors(containerColor = container),
) {
Column(modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp), verticalArrangement = Arrangement.spacedBy(2.dp)) {
Text(label, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
Text(
if (byWeight) "${h.reps} reps på ${h.weight.compact()} kg" else "${h.weight.compact()} kg × ${h.reps}",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary,
)
Text(
buildString {
append(h.date)
h.sessionName?.let { append(" · pass \"$it\"") } ?: append(" · löst lyft")
if (h.count > 1) append(" · ${h.count} lyft i gruppen")
if (h.status == "competition") append(" · 🎖️ tävling")
},
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}

View File

@@ -1,8 +1,12 @@
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.material.icons.filled.Star
import androidx.compose.material.icons.outlined.StarOutline
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -38,13 +42,18 @@ import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory
import eu.brassepc.fitnessdroid.data.GymRepository
import eu.brassepc.fitnessdroid.data.StatsBundle
import eu.brassepc.fitnessdroid.data.userMessage
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 +69,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")
@@ -68,6 +77,19 @@ class StatsViewModel(
val bodyMeasurements = MutableStateFlow<List<eu.brassepc.fitnessdroid.data.BodyMeasurement>>(emptyList())
val activities = MutableStateFlow<List<eu.brassepc.fitnessdroid.data.Activity>>(emptyList())
// ── Personbästa: läge (alla giltiga / tävling / träning) + lista ──
// OBS: måste deklareras FÖRE init-blocket nedan Kotlin initierar i
// deklarationsordning och init { load() } startar refreshPbs() direkt på
// huvudtråden. Låg de efter init var pbMode null vid första hämtningen
// (NPE som runCatching svalde → PB-listan tom tills man bytte period).
val pbMode = MutableStateFlow("all")
val pbs = MutableStateFlow<List<eu.brassepc.fitnessdroid.data.PbEntry>>(emptyList())
/** Servern kör ett äldre gym-API: rekorden visas men status, lägen och sökning saknas. */
val pbLegacyApi = MutableStateFlow(false)
/** Senaste felet vid hämtning av rekorden (visas i stället för "Inga rekord än"). */
val pbError = MutableStateFlow<String?>(null)
val pbLoading = MutableStateFlow(true)
private fun periodStart(): java.time.Instant? {
val now = java.time.Instant.now()
return when (period.value) {
@@ -120,6 +142,42 @@ class StatsViewModel(
StatsUiState.Error("Kunde inte hämta statistiken — offline?")
}
}
refreshPbs()
}
// ── Personbästa: hämtning ──
fun setPbMode(mode: String) {
if (pbMode.value == mode) return
pbMode.value = mode
refreshPbs()
}
fun refreshPbs() {
viewModelScope.launch {
pbLoading.value = true
try {
val result = repo.personalBestsDetailed(pbMode.value)
pbs.value = result.entries
pbLegacyApi.value = result.legacyApi
pbError.value = null
} catch (e: Exception) {
// Behåll det som redan visas men säg vad som gick fel. Tidigare
// svaldes felet och listan såg ut som "Inga rekord än" så såg det
// ut när prod-API:t låg efter appen (2026-09).
pbError.value = e.userMessage("Kunde inte hämta personbästa — offline?")
} finally {
pbLoading.value = false
}
}
}
/** Favorit av/på för en övning: optimistisk omsortering, servern uppdateras i bakgrunden. */
fun togglePbFavorite(entry: eu.brassepc.fitnessdroid.data.PbEntry) {
val next = !entry.isFavorite
pbs.value = pbs.value
.map { if (it.exerciseTypeId == entry.exerciseTypeId) it.copy(isFavorite = next) else it }
.sortedWith(compareByDescending<eu.brassepc.fitnessdroid.data.PbEntry> { it.isFavorite }.thenBy { it.exerciseName })
repo.toggleFavoriteExercise(entry.exerciseTypeId, next)
}
companion object {
@@ -136,6 +194,22 @@ 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()
val pbLegacyApi by viewModel.pbLegacyApi.collectAsStateWithLifecycle()
val pbError by viewModel.pbError.collectAsStateWithLifecycle()
val pbLoading by viewModel.pbLoading.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,21 +417,100 @@ 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,
// Äldre server känner inte till lägen bara "Alla giltiga" går.
enabled = !pbLegacyApi || key == "all",
onClick = { viewModel.setPbMode(key) },
label = { Text(label) },
)
}
}
if (pbLegacyApi) {
Text(
"Personbästa",
style = MaterialTheme.typography.titleMedium,
"Servern kör ett äldre gym-API: rekorden visas, men tävling/räknas ej, detaljer och sökning kräver att gym-API:t uppdateras.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 6.dp),
)
}
items(stats.personalBests, key = { it.exerciseName }) { pb ->
Card(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.padding(14.dp)) {
Text(pb.exerciseName, style = MaterialTheme.typography.titleSmall)
pbError?.let {
Text(
pb.records.joinToString(" ") { "${it.reps}RM ${it.weight.compact()}kg" },
it,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
modifier = Modifier.padding(top = 6.dp),
)
}
}
if (pbs.isNotEmpty() && !pbLegacyApi) {
item {
PbSearchCard(
exercises = pbs,
mode = pbMode,
repo = viewModel.repo,
onOpenLift = { pbSheet = it },
)
}
}
if (pbs.isEmpty() && pbError == null) {
item {
Text(
when {
pbLoading -> "Hämtar rekord…"
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()) {
Column(modifier = Modifier.padding(start = 14.dp, end = 6.dp, top = 4.dp, bottom = 14.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(pb.exerciseName, style = MaterialTheme.typography.titleSmall, modifier = Modifier.weight(1f))
// Favorit = ligger överst (samma favoriter som övningsväljaren)
androidx.compose.material3.IconButton(onClick = { viewModel.togglePbFavorite(pb) }) {
androidx.compose.material3.Icon(
if (pb.isFavorite) Icons.Filled.Star else Icons.Outlined.StarOutline,
contentDescription = if (pb.isFavorite) "Ta bort favorit" else "Gör till favorit",
tint = if (pb.isFavorite) MaterialTheme.colorScheme.tertiary else MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
Row(
modifier = Modifier
.padding(top = 6.dp)
.horizontalScroll(rememberScrollState()),
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
pb.records.forEach { r ->
androidx.compose.material3.AssistChip(
onClick = {
// liftId = 0 på äldre server inget ark att öppna.
if (r.liftId > 0) 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(
if (pbLegacyApi) "Detaljer och rekordstatus kräver ett uppdaterat gym-API på servern."
else "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),
)
@@ -367,7 +520,6 @@ fun StatsScreen(viewModel: StatsViewModel = viewModel(factory = StatsViewModel.F
}
}
}
}
}
private data class CompPart(val label: String, val value: (eu.brassepc.fitnessdroid.data.BodyMeasurement) -> Double?)
@@ -498,9 +650,19 @@ private fun TrendBars(stats: StatsBundle) {
val max = stats.trend.maxOf { it.volumeKg }.takeIf { it > 0 } ?: 1.0
val listState = androidx.compose.foundation.lazy.rememberLazyListState()
// Börja längst till höger — senaste perioden är den intressanta.
// Börja vid senaste stapeln MED volym: servern skickar hela perioden (t.ex.
// resten av månaden), och står vi längst till höger ser man bara tomma
// framtidsdagar. Ingen volym alls: dagens stapel (etikett "d/M") om den
// finns, annars längst till höger.
androidx.compose.runtime.LaunchedEffect(stats.trend.size) {
if (stats.trend.isNotEmpty()) listState.scrollToItem(stats.trend.size - 1)
if (stats.trend.isNotEmpty()) {
val today = java.time.LocalDate.now().let { "${it.dayOfMonth}/${it.monthValue}" }
val target = stats.trend.indexOfLast { it.volumeKg > 0 }
.takeIf { it >= 0 }
?: stats.trend.indexOfFirst { it.label == today }.takeIf { it >= 0 }
?: (stats.trend.size - 1)
listState.scrollToItem(target)
}
}
androidx.compose.foundation.lazy.LazyRow(

63
tools/waydroid-ui.py Normal file
View File

@@ -0,0 +1,63 @@
#!/usr/bin/env python3
"""Liten adb-hjälpare för att styra FitnessDroid i Waydroid (eller på en telefon) från skalet.
Användning: python3 tools/waydroid-ui.py <dump|tap <text>|shot <namn> [skala]|swipe up|down [andel]|text <str>|key <KEYCODE>|tapxy <x> <y>>
Skärmdumpar hamnar i /tmp/shots/. Enhet via env ADB_SERIAL (standard Waydroid 192.168.240.112:5555).
Telefonlik yta i Waydroid: adb shell wm size 720x1560 && adb shell wm density 280 (återställ: wm size reset; wm density reset).
"""
import re, subprocess, sys, time
from PIL import Image
import os
D = ["adb", "-s", os.environ.get("ADB_SERIAL", "192.168.240.112:5555")]
def sh(*a, **k): return subprocess.run(D + list(a), capture_output=True, text=True, **k).stdout
def nodes():
sh("shell", "uiautomator", "dump", "/sdcard/ui.xml")
x = sh("shell", "cat", "/sdcard/ui.xml")
out = []
for m in re.finditer(r'<node[^>]*?>', x):
n = m.group(0)
g = lambda k: (re.search(k + r'="([^"]*)"', n) or [None, ""])[1]
b = re.search(r'bounds="\[(\d+),(\d+)\]\[(\d+),(\d+)\]"', n)
if not b: continue
out.append(dict(cls=g("class").split(".")[-1], text=g("text").replace("&amp;", "&").replace("&#127942;", "🏆"), desc=g("content-desc"),
b=tuple(map(int, b.groups())), enabled=g("enabled"), checked=g("checked"), selected=g("selected")))
return out
def win(ns=None):
ns = ns or nodes()
for n in ns:
if n["desc"].startswith("Caption bar of FitnessDroid"): return n["b"]
return (0, 0, 2560, 1408)
def center(b): return (b[0] + b[2]) // 2, (b[1] + b[3]) // 2
def cmd_dump():
ns = nodes(); w = win(ns)
print("WIN", w)
for n in ns:
if n["text"] or n["desc"] or n["cls"] in ("EditText", "Switch", "CheckBox", "RadioButton"):
flags = "".join(f for f, v in (("E", n["enabled"] == "false"), ("", n["checked"] == "true"), ("S", n["selected"] == "true")) if v)
print(f'{n["cls"]:10} {n["text"][:90]!r:95} {n["desc"][:30]!r:34} {n["b"]} {flags}')
def cmd_tap(label, idx=0):
ns = nodes(); hits = [n for n in ns if n["text"] == label or n["desc"] == label]
if not hits: hits = [n for n in ns if label.lower() in (n["text"] + n["desc"]).lower()]
if not hits: print("NOTFOUND", label); return 1
x, y = center(hits[int(idx)]["b"]); sh("shell", "input", "tap", str(x), str(y)); print("TAP", label, x, y)
def cmd_shot(name, scale=0.5):
scale = float(scale)
ns = nodes(); w = win(ns)
os.makedirs("/tmp/shots", exist_ok=True)
png = subprocess.run(D + ["exec-out", "screencap", "-p"], capture_output=True).stdout
open("/tmp/shots/_full.png", "wb").write(png)
im = Image.open("/tmp/shots/_full.png").crop(w)
if scale != 1: im = im.resize((int(im.width * scale), int(im.height * scale)))
p = f"/tmp/shots/{name}.png"; im.save(p); print("SHOT", p, im.size, "win", w)
def cmd_swipe(direction, frac=0.6):
frac = float(frac)
w = win(); x = w[0] + int((w[2] - w[0]) * 0.3); h = w[3] - w[1]
y1, y2 = (w[1] + int(h * 0.85), w[1] + int(h * (0.85 - frac))) if direction == "up" else (w[1] + int(h * 0.25), w[1] + int(h * (0.25 + frac)))
sh("shell", "input", "swipe", str(x), str(y1), str(x), str(y2), "500"); print("SWIPE", direction)
def cmd_text(s): sh("shell", "input", "text", s.replace(" ", "%s")); print("TEXT", s)
def cmd_key(k): sh("shell", "input", "keyevent", k); print("KEY", k)
def cmd_tapxy(x, y): sh("shell", "input", "tap", x, y); print("TAPXY", x, y)
if __name__ == "__main__":
a = sys.argv[1:]; c = a[0]
{"dump": lambda: cmd_dump(), "tap": lambda: cmd_tap(*a[1:]), "shot": lambda: cmd_shot(*a[1:]),
"swipe": lambda: cmd_swipe(*a[1:]), "text": lambda: cmd_text(a[1]), "key": lambda: cmd_key(a[1]), "tapxy": lambda: cmd_tapxy(a[1], a[2])}[c]()