Compare commits
5 Commits
923f58420d
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 9a7b564e49 | |||
| 213f635d93 | |||
| 6ce8853309 | |||
| 8f7cd9a419 | |||
| c2161869bb |
30
README.md
30
README.md
@@ -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
|
||||
|
||||
@@ -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,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}}}",
|
||||
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,
|
||||
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(
|
||||
|
||||
@@ -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()}") }
|
||||
},
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,10 +154,32 @@ 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 {
|
||||
initializer {
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
pbError?.let {
|
||||
Text(
|
||||
it,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
modifier = Modifier.padding(top = 6.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
if (pbs.isEmpty()) {
|
||||
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
63
tools/waydroid-ui.py
Normal 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("&", "&").replace("🏆", "🏆"), 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]()
|
||||
Reference in New Issue
Block a user