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
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:
19
README.md
19
README.md
@@ -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
|
||||
|
||||
@@ -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>".
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user