diff --git a/README.md b/README.md index 97936f0..172c779 100644 --- a/README.md +++ b/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 diff --git a/app/src/main/java/eu/brassepc/fitnessdroid/data/GraphQlClient.kt b/app/src/main/java/eu/brassepc/fitnessdroid/data/GraphQlClient.kt index 99fac30..e361523 100644 --- a/app/src/main/java/eu/brassepc/fitnessdroid/data/GraphQlClient.kt +++ b/app/src/main/java/eu/brassepc/fitnessdroid/data/GraphQlClient.kt @@ -17,6 +17,26 @@ import java.util.concurrent.TimeUnit class GraphQlException(val messages: List) : 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 ". diff --git a/app/src/main/java/eu/brassepc/fitnessdroid/data/GymRepository.kt b/app/src/main/java/eu/brassepc/fitnessdroid/data/GymRepository.kt index 605cb0a..c6f034c 100644 --- a/app/src/main/java/eu/brassepc/fitnessdroid/data/GymRepository.kt +++ b/app/src/main/java/eu/brassepc/fitnessdroid/data/GymRepository.kt @@ -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 { - 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 = emptySet(), + ): List = + 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 { 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 = runCatching { personalBestsDetailed("all") }.getOrDefault(emptyList()) + val pbs: List = 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, val legacyApi: Boolean = false) + data class PbLookupHit( val weight: Double, val reps: Int, diff --git a/app/src/main/java/eu/brassepc/fitnessdroid/ui/common/LiftStatusSheet.kt b/app/src/main/java/eu/brassepc/fitnessdroid/ui/common/LiftStatusSheet.kt index 0106e1c..4bf5861 100644 --- a/app/src/main/java/eu/brassepc/fitnessdroid/ui/common/LiftStatusSheet.kt +++ b/app/src/main/java/eu/brassepc/fitnessdroid/ui/common/LiftStatusSheet.kt @@ -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 } diff --git a/app/src/main/java/eu/brassepc/fitnessdroid/ui/stats/PbSearchCard.kt b/app/src/main/java/eu/brassepc/fitnessdroid/ui/stats/PbSearchCard.kt index 76bfc5e..e6a8d02 100644 --- a/app/src/main/java/eu/brassepc/fitnessdroid/ui/stats/PbSearchCard.kt +++ b/app/src/main/java/eu/brassepc/fitnessdroid/ui/stats/PbSearchCard.kt @@ -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 } diff --git a/app/src/main/java/eu/brassepc/fitnessdroid/ui/stats/StatsScreen.kt b/app/src/main/java/eu/brassepc/fitnessdroid/ui/stats/StatsScreen.kt index f0f5ce6..0851467 100644 --- a/app/src/main/java/eu/brassepc/fitnessdroid/ui/stats/StatsScreen.kt +++ b/app/src/main/java/eu/brassepc/fitnessdroid/ui/stats/StatsScreen.kt @@ -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>(emptyList()) val activities = MutableStateFlow>(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>(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(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>(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(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),