Compare commits

...

5 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
8 changed files with 535 additions and 30 deletions

View File

@@ -41,6 +41,23 @@ 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
@@ -53,9 +70,20 @@ ett rekord så öppnas ett ark med vikt, est. 1RM, datum, pass och anteckning, d
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
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

@@ -299,18 +299,51 @@ class GymRepository(
/* ---------- 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}}}",
/**
* 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) },
auth.bearerToken(),
token,
)
return pb["personalBests"]!!.jsonArray.map { p ->
} 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 = po["exerciseTypeId"]?.jsonPrimitive?.intOrNull ?: 0,
exerciseTypeId = typeId,
isFavorite = po["isFavorite"]?.jsonPrimitive?.booleanOrNull ?: (typeId in favorites),
records = po["repRecords"]!!.jsonArray.mapNotNull { r ->
val ro = r.jsonObject
PbRecord(
@@ -326,9 +359,49 @@ class GymRepository(
)
}.sortedBy { it.reps },
)
}.sortedBy { it.exerciseName }
}.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(
@@ -742,7 +815,7 @@ class GymRepository(
}
}.getOrDefault(emptyMap())
val pbs: List<PbEntry> = runCatching { personalBestsDetailed("all") }.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
@@ -944,7 +1017,45 @@ data class PbRecord(
val sessionName: String? = null,
val est1Rm: Double = 0.0,
)
data class PbEntry(val exerciseName: String, val records: List<PbRecord>, val exerciseTypeId: Int = 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(

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

@@ -30,6 +30,7 @@ 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
@@ -118,7 +119,7 @@ fun LiftStatusSheet(
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?"
error = e.userMessage("Kunde inte hämta lyftet — offline?")
} finally {
loading = false
}
@@ -219,7 +220,7 @@ fun LiftStatusSheet(
onChanged()
load(keepId = f?.id)
} catch (e: Exception) {
error = "Kunde inte spara — offline?"
error = e.userMessage("Kunde inte spara — offline?")
} finally {
saving = false
}
@@ -232,7 +233,7 @@ fun LiftStatusSheet(
// ── Topplista ──
if (candidates.isNotEmpty()) {
Text(
"Topplista · ${target.reps} reps — tryck för att välja ett annat lyft",
"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),
)

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

@@ -5,6 +5,8 @@ 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
@@ -40,6 +42,7 @@ 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
@@ -74,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) {
@@ -129,10 +145,7 @@ class StatsViewModel(
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())
// ── Personbästa: hämtning ──
fun setPbMode(mode: String) {
if (pbMode.value == mode) return
pbMode.value = mode
@@ -141,9 +154,31 @@ class StatsViewModel(
fun refreshPbs() {
viewModelScope.launch {
runCatching { pbs.value = repo.personalBestsDetailed(pbMode.value) }
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 {
val Factory: ViewModelProvider.Factory = viewModelFactory {
@@ -161,6 +196,9 @@ fun StatsScreen(viewModel: StatsViewModel = viewModel(factory = StatsViewModel.F
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 ->
@@ -390,17 +428,48 @@ fun StatsScreen(viewModel: StatsViewModel = viewModel(factory = StatsViewModel.F
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(
"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),
)
}
if (pbs.isEmpty()) {
pbError?.let {
Text(
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(
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.",
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,
)
@@ -408,8 +477,18 @@ fun StatsScreen(viewModel: StatsViewModel = viewModel(factory = StatsViewModel.F
}
items(pbs, key = { "${it.exerciseTypeId}-${it.exerciseName}" }) { pb ->
Card(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.padding(14.dp)) {
Text(pb.exerciseName, style = MaterialTheme.typography.titleSmall)
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)
@@ -419,7 +498,8 @@ fun StatsScreen(viewModel: StatsViewModel = viewModel(factory = StatsViewModel.F
pb.records.forEach { r ->
androidx.compose.material3.AssistChip(
onClick = {
pbSheet = LiftStatusTarget.Lift(pb.exerciseTypeId, pb.exerciseName, r.reps, r.liftId)
// 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 ""}")
@@ -428,7 +508,8 @@ fun StatsScreen(viewModel: StatsViewModel = viewModel(factory = StatsViewModel.F
}
}
Text(
"Tryck på ett rekord för detaljer, tävling eller räknas ej.",
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),
@@ -569,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]()