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
This commit is contained in:
2026-09-10 00:24:49 +02:00
parent 6ce8853309
commit 213f635d93
6 changed files with 154 additions and 25 deletions

View File

@@ -41,6 +41,17 @@ 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`.
### Testa mot API:t
Logga in med ditt vanliga gym-konto (LDAP). API-url:en går att ändra under
@@ -59,6 +70,14 @@ bästa est. 1RM. Stjärnan på ett PB-kort gör övningen till **favorit** (samm
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,19 +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 isFavorite repRecords{reps weight date liftId notes status statusNote sessionName estimated1Rm}}}",
buildJsonObject { put("m", mode) },
auth.bearerToken(),
/**
* 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,
)
return pb["personalBests"]!!.jsonArray.map { p ->
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,
isFavorite = po["isFavorite"]?.jsonPrimitive?.booleanOrNull ?: false,
exerciseTypeId = typeId,
isFavorite = po["isFavorite"]?.jsonPrimitive?.booleanOrNull ?: (typeId in favorites),
records = po["repRecords"]!!.jsonArray.mapNotNull { r ->
val ro = r.jsonObject
PbRecord(
@@ -328,7 +360,6 @@ class GymRepository(
}.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
@@ -784,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
@@ -994,6 +1025,13 @@ data class PbEntry(
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,

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
}

View File

@@ -31,6 +31,7 @@ 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
@@ -74,7 +75,7 @@ fun PbSearchCard(
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 = "Kunde inte söka — offline?"
error = e.userMessage("Kunde inte söka — offline?")
} finally {
loading = false
}

View File

@@ -42,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
@@ -76,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) {
@@ -131,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
@@ -143,7 +154,20 @@ 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
}
}
}
@@ -172,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 ->
@@ -401,13 +428,31 @@ 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),
)
}
pbError?.let {
Text(
it,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
modifier = Modifier.padding(top = 6.dp),
)
}
}
if (pbs.isNotEmpty()) {
if (pbs.isNotEmpty() && !pbLegacyApi) {
item {
PbSearchCard(
exercises = pbs,
@@ -417,11 +462,14 @@ fun StatsScreen(viewModel: StatsViewModel = viewModel(factory = StatsViewModel.F
)
}
}
if (pbs.isEmpty()) {
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,
)
@@ -450,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 ""}")
@@ -459,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),