Compare commits
7 Commits
d2a37f21de
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 9a7b564e49 | |||
| 213f635d93 | |||
| 6ce8853309 | |||
| 8f7cd9a419 | |||
| c2161869bb | |||
| 923f58420d | |||
| 139a4c20ef |
@@ -5,7 +5,8 @@ name: release
|
|||||||
#
|
#
|
||||||
# Byggs ALLTID på brasse-linux01 (label linux-amd64) — Pi5:n OOM:ar på
|
# Byggs ALLTID på brasse-linux01 (label linux-amd64) — Pi5:n OOM:ar på
|
||||||
# APK-bygget, och Gitea saknar runner-prioritet så delade labels gav slumpen.
|
# APK-bygget, och Gitea saknar runner-prioritet så delade labels gav slumpen.
|
||||||
# Är huvuddatorn av köas jobbet tills runnern är tillbaka.
|
# Är huvuddatorn av köas jobbet tills runnern är tillbaka. Första steget
|
||||||
|
# vägrar dessutom köra på arm64 (2026-09-06), som skydd om labels ändras.
|
||||||
# På arm64 används en statiskt byggd aapt2 (lzhiyong/android-sdk-tools) —
|
# På arm64 används en statiskt byggd aapt2 (lzhiyong/android-sdk-tools) —
|
||||||
# Googles aapt2 finns bara för linux x86_64. Resten av Android-bygget är
|
# Googles aapt2 finns bara för linux x86_64. Resten av Android-bygget är
|
||||||
# JVM-baserat och arkitekturoberoende.
|
# JVM-baserat och arkitekturoberoende.
|
||||||
@@ -22,6 +23,17 @@ jobs:
|
|||||||
build-release:
|
build-release:
|
||||||
runs-on: linux-amd64
|
runs-on: linux-amd64
|
||||||
steps:
|
steps:
|
||||||
|
# Säkerhetsspärr: APK-bygget får ALDRIG köra på Pi5:n (OOM ⇒ hela servern
|
||||||
|
# nere). Om jobbet ändå hamnar på en arm64-runner (t.ex. om Pi-runnern
|
||||||
|
# fått labeln linux-amd64) avbryts det här, innan något tungt startat.
|
||||||
|
- name: Vägra bygga på arm64/Pi5
|
||||||
|
run: |
|
||||||
|
if [ "$(uname -m)" != "x86_64" ]; then
|
||||||
|
echo "::error::Fel runner ($(uname -m), $(hostname)). APK-bygget ska köras på brasse-linux01 (label linux-amd64). Avbryter för att inte ta ner Pi5:n."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "Runner OK: $(uname -m) på $(hostname)"
|
||||||
|
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
|||||||
38
README.md
38
README.md
@@ -41,11 +41,49 @@ 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
|
`build/FitnessDroid.apk`. Tasken `installera på telefon (adb)` installerar den
|
||||||
direkt på en ansluten telefon med USB-felsökning på.
|
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
|
### Testa mot API:t
|
||||||
|
|
||||||
Logga in med ditt vanliga gym-konto (LDAP). API-url:en går att ändra under
|
Logga in med ditt vanliga gym-konto (LDAP). API-url:en går att ändra under
|
||||||
"Avancerat" på inloggningsskärmen (standard: `https://gymapi.brasse-pc.eu/graphql`).
|
"Avancerat" på inloggningsskärmen (standard: `https://gymapi.brasse-pc.eu/graphql`).
|
||||||
|
|
||||||
|
## Rekordstatus: träning / tävling / räknas ej
|
||||||
|
|
||||||
|
Under **Statistik → Personbästa** finns växeln *Alla giltiga · Tävling · Träning*. Tryck på
|
||||||
|
ett rekord så öppnas ett ark med vikt, est. 1RM, datum, pass och anteckning, där lyftet kan
|
||||||
|
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. 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
|
## Signeringsnyckeln
|
||||||
|
|
||||||
Nyckeln är **inte** incheckad (repot är publikt). Lokalt ligger den i
|
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("; "))
|
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
|
* Minimal GraphQL-klient över OkHttp. Samma request-form som webbklienten
|
||||||
* (gymTrackerApi.js): POST {query, variables} + "Authorization: Bearer <jwt>".
|
* (gymTrackerApi.js): POST {query, variables} + "Authorization: Bearer <jwt>".
|
||||||
|
|||||||
@@ -25,6 +25,9 @@ import kotlinx.serialization.json.put
|
|||||||
* Lokal-först-repository. Allt UI läser Room-flows; alla skrivningar sker
|
* Lokal-först-repository. Allt UI läser Room-flows; alla skrivningar sker
|
||||||
* lokalt först och läggs på op-kön som [SyncEngine] driver mot servern.
|
* lokalt först och läggs på op-kön som [SyncEngine] driver mot servern.
|
||||||
*/
|
*/
|
||||||
|
private const val LIFT_DETAIL_FIELDS =
|
||||||
|
"id exerciseTypeId exerciseTypeName weight reps date notes status statusNote gymSessionId sessionName gymSessionSetId estimated1Rm isCurrentRecord"
|
||||||
|
|
||||||
class GymRepository(
|
class GymRepository(
|
||||||
private val db: AppDatabase,
|
private val db: AppDatabase,
|
||||||
private val client: GraphQlClient,
|
private val client: GraphQlClient,
|
||||||
@@ -265,6 +268,196 @@ class GymRepository(
|
|||||||
suspend fun personalBests(): Map<Pair<Int, Int>, Double> =
|
suspend fun personalBests(): Map<Pair<Int, Int>, Double> =
|
||||||
db.cacheDao().personalBests().associate { (it.exerciseTypeId to it.reps) to it.weight }
|
db.cacheDao().personalBests().associate { (it.exerciseTypeId to it.reps) to it.weight }
|
||||||
|
|
||||||
|
|
||||||
|
/** PB-cachen (för 🏆-märkning av set under pass) – bara giltiga lyft, alla lägen. */
|
||||||
|
suspend fun refreshPbCache(token: String? = null) {
|
||||||
|
val tok = token ?: auth.bearerToken()
|
||||||
|
|
||||||
|
val pbs = client.execute(
|
||||||
|
"query{personalBests{exerciseTypeId repRecords{reps weight}}}",
|
||||||
|
token = tok,
|
||||||
|
)
|
||||||
|
val rows = pbs["personalBests"]!!.jsonArray.flatMap { p ->
|
||||||
|
val po = p.jsonObject
|
||||||
|
val typeId = po["exerciseTypeId"]!!.jsonPrimitive.int
|
||||||
|
po["repRecords"]!!.jsonArray.mapNotNull { r ->
|
||||||
|
val ro = r.jsonObject
|
||||||
|
val reps = ro["reps"]?.jsonPrimitive?.intOrNull ?: return@mapNotNull null
|
||||||
|
val weight = ro["weight"]?.jsonPrimitive?.doubleOrNull ?: return@mapNotNull null
|
||||||
|
eu.brassepc.fitnessdroid.data.local.CachedPb(
|
||||||
|
key = "$typeId-$reps",
|
||||||
|
exerciseTypeId = typeId,
|
||||||
|
reps = reps,
|
||||||
|
weight = weight,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
db.cacheDao().clearPersonalBests()
|
||||||
|
db.cacheDao().upsertPersonalBests(rows)
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Rekordstatus (träning / tävling / räknas ej) ---------- */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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,
|
||||||
|
)
|
||||||
|
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 = typeId,
|
||||||
|
isFavorite = po["isFavorite"]?.jsonPrimitive?.booleanOrNull ?: (typeId in favorites),
|
||||||
|
records = po["repRecords"]!!.jsonArray.mapNotNull { r ->
|
||||||
|
val ro = r.jsonObject
|
||||||
|
PbRecord(
|
||||||
|
reps = ro["reps"]?.jsonPrimitive?.intOrNull ?: return@mapNotNull null,
|
||||||
|
weight = ro["weight"]?.jsonPrimitive?.doubleOrNull ?: return@mapNotNull null,
|
||||||
|
date = ro["date"]?.jsonPrimitive?.contentOrNull()?.take(10) ?: "",
|
||||||
|
liftId = ro["liftId"]?.jsonPrimitive?.intOrNull ?: 0,
|
||||||
|
status = ro["status"]?.jsonPrimitive?.contentOrNull() ?: "training",
|
||||||
|
statusNote = ro["statusNote"]?.jsonPrimitive?.contentOrNull(),
|
||||||
|
notes = ro["notes"]?.jsonPrimitive?.contentOrNull(),
|
||||||
|
sessionName = ro["sessionName"]?.jsonPrimitive?.contentOrNull(),
|
||||||
|
est1Rm = ro["estimated1Rm"]?.jsonPrimitive?.doubleOrNull ?: 0.0,
|
||||||
|
)
|
||||||
|
}.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
|
||||||
|
* 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(
|
||||||
|
"query(\$e:Int!,\$r:Int!,\$l:Int,\$m:String){pbCandidates(exerciseTypeId:\$e,reps:\$r,limit:\$l,mode:\$m){$LIFT_DETAIL_FIELDS}}",
|
||||||
|
buildJsonObject { put("e", exerciseTypeId); put("r", reps); put("l", limit); put("m", mode) },
|
||||||
|
auth.bearerToken(),
|
||||||
|
)
|
||||||
|
return d["pbCandidates"]!!.jsonArray.map { parseLiftDetail(it.jsonObject) }
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun liftDetail(id: Int): LiftDetail? {
|
||||||
|
val d = client.execute(
|
||||||
|
"query(\$id:Int!){liftDetail(id:\$id){$LIFT_DETAIL_FIELDS}}",
|
||||||
|
buildJsonObject { put("id", id) },
|
||||||
|
auth.bearerToken(),
|
||||||
|
)
|
||||||
|
return d["liftDetail"]?.takeIf { it !is kotlinx.serialization.json.JsonNull }?.let { parseLiftDetail(it.jsonObject) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sätter status på ett lyft (och dess set). Uppdaterar PB-cachen efteråt. */
|
||||||
|
suspend fun setLiftStatus(id: Int, status: String, note: String?) {
|
||||||
|
client.execute(
|
||||||
|
"mutation(\$input:SetLiftStatusInput!){setLiftStatus(input:\$input){id status statusNote}}",
|
||||||
|
buildJsonObject { put("input", buildJsonObject { put("id", id); put("status", status); note?.let { put("note", it) } }) },
|
||||||
|
auth.bearerToken(),
|
||||||
|
)
|
||||||
|
runCatching { refreshPbCache() }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sätter status på ett set i ett pass (och dess lyft om passet är avslutat). */
|
||||||
|
suspend fun setSessionSetStatus(setId: Int, status: String, note: String?) {
|
||||||
|
client.execute(
|
||||||
|
"mutation(\$input:SetSessionSetStatusInput!){setSessionSetStatus(input:\$input){id status statusNote}}",
|
||||||
|
buildJsonObject { put("input", buildJsonObject { put("setId", setId); put("status", status); note?.let { put("note", it) } }) },
|
||||||
|
auth.bearerToken(),
|
||||||
|
)
|
||||||
|
runCatching { refreshPbCache() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseLiftDetail(o: kotlinx.serialization.json.JsonObject) = LiftDetail(
|
||||||
|
id = o["id"]!!.jsonPrimitive.int,
|
||||||
|
exerciseTypeId = o["exerciseTypeId"]?.jsonPrimitive?.intOrNull ?: 0,
|
||||||
|
exerciseName = o["exerciseTypeName"]?.jsonPrimitive?.contentOrNull() ?: "",
|
||||||
|
weight = o["weight"]?.jsonPrimitive?.doubleOrNull ?: 0.0,
|
||||||
|
reps = o["reps"]?.jsonPrimitive?.intOrNull ?: 0,
|
||||||
|
date = o["date"]?.jsonPrimitive?.contentOrNull()?.take(10) ?: "",
|
||||||
|
notes = o["notes"]?.jsonPrimitive?.contentOrNull(),
|
||||||
|
status = o["status"]?.jsonPrimitive?.contentOrNull() ?: "training",
|
||||||
|
statusNote = o["statusNote"]?.jsonPrimitive?.contentOrNull(),
|
||||||
|
sessionId = o["gymSessionId"]?.jsonPrimitive?.intOrNull,
|
||||||
|
sessionName = o["sessionName"]?.jsonPrimitive?.contentOrNull(),
|
||||||
|
setId = o["gymSessionSetId"]?.jsonPrimitive?.intOrNull,
|
||||||
|
est1Rm = o["estimated1Rm"]?.jsonPrimitive?.doubleOrNull ?: 0.0,
|
||||||
|
isCurrentRecord = o["isCurrentRecord"]?.jsonPrimitive?.booleanOrNull ?: false,
|
||||||
|
)
|
||||||
|
|
||||||
/* ---------- Referensdata / cache ---------- */
|
/* ---------- Referensdata / cache ---------- */
|
||||||
|
|
||||||
/** Hämta om all referensdata. Tyst vid nätfel — cachen gäller tills vidare. */
|
/** Hämta om all referensdata. Tyst vid nätfel — cachen gäller tills vidare. */
|
||||||
@@ -314,29 +507,7 @@ class GymRepository(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
runCatching {
|
runCatching { refreshPbCache(token) }
|
||||||
val pbs = client.execute(
|
|
||||||
"query{personalBests{exerciseTypeId repRecords{reps weight}}}",
|
|
||||||
token = token,
|
|
||||||
)
|
|
||||||
val rows = pbs["personalBests"]!!.jsonArray.flatMap { p ->
|
|
||||||
val po = p.jsonObject
|
|
||||||
val typeId = po["exerciseTypeId"]!!.jsonPrimitive.int
|
|
||||||
po["repRecords"]!!.jsonArray.mapNotNull { r ->
|
|
||||||
val ro = r.jsonObject
|
|
||||||
val reps = ro["reps"]?.jsonPrimitive?.intOrNull ?: return@mapNotNull null
|
|
||||||
val weight = ro["weight"]?.jsonPrimitive?.doubleOrNull ?: return@mapNotNull null
|
|
||||||
eu.brassepc.fitnessdroid.data.local.CachedPb(
|
|
||||||
key = "$typeId-$reps",
|
|
||||||
exerciseTypeId = typeId,
|
|
||||||
reps = reps,
|
|
||||||
weight = weight,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
db.cacheDao().clearPersonalBests()
|
|
||||||
db.cacheDao().upsertPersonalBests(rows)
|
|
||||||
}
|
|
||||||
|
|
||||||
runCatching {
|
runCatching {
|
||||||
val prof = client.execute("query{myProfile{bodyWeightKg displayName}}", token = token)
|
val prof = client.execute("query{myProfile{bodyWeightKg displayName}}", token = token)
|
||||||
@@ -520,7 +691,7 @@ class GymRepository(
|
|||||||
/** Full detalj för ett avslutat pass. */
|
/** Full detalj för ett avslutat pass. */
|
||||||
suspend fun fetchSessionDetail(id: Int): SessionDetail {
|
suspend fun fetchSessionDetail(id: Int): SessionDetail {
|
||||||
val data = client.execute(
|
val data = client.execute(
|
||||||
"query(\$id:Int!){gymSession(id:\$id){id name notes date startTime endTime durationSecondsOverride estimatedCalories gymSessionExercises{order exerciseType{name} sets{order reps weight distanceMeters durationSeconds rpe isWarmup}}}}",
|
"query(\$id:Int!){gymSession(id:\$id){id name notes date startTime endTime durationSecondsOverride estimatedCalories gymSessionExercises{order exerciseType{id name} sets{id order reps weight distanceMeters durationSeconds rpe isWarmup notes status statusNote}}}}",
|
||||||
buildJsonObject { put("id", id) },
|
buildJsonObject { put("id", id) },
|
||||||
auth.bearerToken(),
|
auth.bearerToken(),
|
||||||
)
|
)
|
||||||
@@ -546,6 +717,7 @@ class GymRepository(
|
|||||||
?: "Övning",
|
?: "Övning",
|
||||||
sets = eo["sets"]!!.jsonArray.map(::parseHistorySet)
|
sets = eo["sets"]!!.jsonArray.map(::parseHistorySet)
|
||||||
.sortedBy { it.order },
|
.sortedBy { it.order },
|
||||||
|
exerciseTypeId = eo["exerciseType"]?.jsonObject?.get("id")?.jsonPrimitive?.intOrNull ?: 0,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -603,6 +775,10 @@ class GymRepository(
|
|||||||
durationSeconds = so["durationSeconds"]?.jsonPrimitive?.intOrNull,
|
durationSeconds = so["durationSeconds"]?.jsonPrimitive?.intOrNull,
|
||||||
rpe = so["rpe"]?.jsonPrimitive?.doubleOrNull,
|
rpe = so["rpe"]?.jsonPrimitive?.doubleOrNull,
|
||||||
isWarmup = so["isWarmup"]?.jsonPrimitive?.booleanOrNull ?: false,
|
isWarmup = so["isWarmup"]?.jsonPrimitive?.booleanOrNull ?: false,
|
||||||
|
id = so["id"]?.jsonPrimitive?.intOrNull ?: 0,
|
||||||
|
notes = so["notes"]?.jsonPrimitive?.contentOrNull(),
|
||||||
|
status = so["status"]?.jsonPrimitive?.contentOrNull() ?: "training",
|
||||||
|
statusNote = so["statusNote"]?.jsonPrimitive?.contentOrNull(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -639,26 +815,7 @@ class GymRepository(
|
|||||||
}
|
}
|
||||||
}.getOrDefault(emptyMap())
|
}.getOrDefault(emptyMap())
|
||||||
|
|
||||||
val pbs: List<PbEntry> = runCatching {
|
val pbs: List<PbEntry> = runCatching { personalBestsDetailed("all").entries }.getOrDefault(emptyList())
|
||||||
val pb = client.execute(
|
|
||||||
"query{personalBests{exerciseTypeName repRecords{reps weight date}}}",
|
|
||||||
token = token,
|
|
||||||
)
|
|
||||||
pb["personalBests"]!!.jsonArray.map { p ->
|
|
||||||
val po = p.jsonObject
|
|
||||||
PbEntry(
|
|
||||||
exerciseName = po["exerciseTypeName"]?.jsonPrimitive?.contentOrNull() ?: "Övning",
|
|
||||||
records = po["repRecords"]!!.jsonArray.mapNotNull { r ->
|
|
||||||
val ro = r.jsonObject
|
|
||||||
PbRecord(
|
|
||||||
reps = ro["reps"]?.jsonPrimitive?.intOrNull ?: return@mapNotNull null,
|
|
||||||
weight = ro["weight"]?.jsonPrimitive?.doubleOrNull ?: return@mapNotNull null,
|
|
||||||
date = ro["date"]?.jsonPrimitive?.contentOrNull()?.take(10) ?: "",
|
|
||||||
)
|
|
||||||
}.sortedBy { it.reps },
|
|
||||||
)
|
|
||||||
}.sortedBy { it.exerciseName }
|
|
||||||
}.getOrDefault(emptyList())
|
|
||||||
|
|
||||||
fun kpi(key: String) = s[key]?.jsonPrimitive?.doubleOrNull ?: 0.0
|
fun kpi(key: String) = s[key]?.jsonPrimitive?.doubleOrNull ?: 0.0
|
||||||
fun prevKpi(key: String) = prev?.get(key)?.jsonPrimitive?.doubleOrNull
|
fun prevKpi(key: String) = prev?.get(key)?.jsonPrimitive?.doubleOrNull
|
||||||
@@ -786,6 +943,11 @@ data class HistorySet(
|
|||||||
val durationSeconds: Int?,
|
val durationSeconds: Int?,
|
||||||
val rpe: Double?,
|
val rpe: Double?,
|
||||||
val isWarmup: Boolean,
|
val isWarmup: Boolean,
|
||||||
|
val id: Int = 0,
|
||||||
|
val notes: String? = null,
|
||||||
|
/** Rekordstatus: training / competition / excluded (se LiftStatusSheet). */
|
||||||
|
val status: String = "training",
|
||||||
|
val statusNote: String? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
data class SessionDetail(
|
data class SessionDetail(
|
||||||
@@ -801,6 +963,7 @@ data class SessionDetail(
|
|||||||
data class SessionDetailExercise(
|
data class SessionDetailExercise(
|
||||||
val name: String,
|
val name: String,
|
||||||
val sets: List<HistorySet>,
|
val sets: List<HistorySet>,
|
||||||
|
val exerciseTypeId: Int = 0,
|
||||||
)
|
)
|
||||||
|
|
||||||
data class ExerciseHistoryEntry(
|
data class ExerciseHistoryEntry(
|
||||||
@@ -843,8 +1006,74 @@ data class BodyMeasurement(
|
|||||||
)
|
)
|
||||||
|
|
||||||
data class TrendBucket(val label: String, val volumeKg: Double, val sessionCount: Int)
|
data class TrendBucket(val label: String, val volumeKg: Double, val sessionCount: Int)
|
||||||
data class PbRecord(val reps: Int, val weight: Double, val date: String)
|
data class PbRecord(
|
||||||
data class PbEntry(val exerciseName: String, val records: List<PbRecord>)
|
val reps: Int,
|
||||||
|
val weight: Double,
|
||||||
|
val date: String,
|
||||||
|
val liftId: Int = 0,
|
||||||
|
val status: String = "training",
|
||||||
|
val statusNote: String? = null,
|
||||||
|
val notes: String? = null,
|
||||||
|
val sessionName: String? = null,
|
||||||
|
val est1Rm: Double = 0.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(
|
||||||
|
val id: Int,
|
||||||
|
val exerciseTypeId: Int,
|
||||||
|
val exerciseName: String,
|
||||||
|
val weight: Double,
|
||||||
|
val reps: Int,
|
||||||
|
val date: String,
|
||||||
|
val notes: String?,
|
||||||
|
val status: String,
|
||||||
|
val statusNote: String?,
|
||||||
|
val sessionId: Int?,
|
||||||
|
val sessionName: String?,
|
||||||
|
val setId: Int?,
|
||||||
|
val est1Rm: Double,
|
||||||
|
val isCurrentRecord: Boolean,
|
||||||
|
)
|
||||||
|
|
||||||
data class StatsBundle(
|
data class StatsBundle(
|
||||||
val sessionCount: Int,
|
val sessionCount: Int,
|
||||||
|
|||||||
@@ -435,7 +435,9 @@ private fun ActivityRow(a: Activity, onClick: () -> Unit, onShowMap: (() -> Unit
|
|||||||
a.distanceMeters?.let {
|
a.distanceMeters?.let {
|
||||||
append(" · ${(it / 1000).compact()} km")
|
append(" · ${(it / 1000).compact()} km")
|
||||||
val kmh = (it / 1000) / (a.durationSeconds / 3600.0)
|
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()}") }
|
a.rpe?.let { append(" · RPE ${it.toInt()}") }
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,281 @@
|
|||||||
|
package eu.brassepc.fitnessdroid.ui.common
|
||||||
|
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
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.material3.Button
|
||||||
|
import androidx.compose.material3.Card
|
||||||
|
import androidx.compose.material3.CardDefaults
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.ModalBottomSheet
|
||||||
|
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.LaunchedEffect
|
||||||
|
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.unit.dp
|
||||||
|
import eu.brassepc.fitnessdroid.data.userMessage
|
||||||
|
import eu.brassepc.fitnessdroid.data.GymRepository
|
||||||
|
import eu.brassepc.fitnessdroid.data.LiftDetail
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rekordstatus på ett lyft: träning (standard), tävling eller räknas ej, med
|
||||||
|
* kommentar. Öppnas från PB-listan (Statistik), passhistoriken och det aktiva
|
||||||
|
* passet. Visar detaljer om lyftet och en topplista ("näst i tur") för samma
|
||||||
|
* övning × reps där man kan välja ett annat lyft att ändra.
|
||||||
|
*/
|
||||||
|
sealed interface LiftStatusTarget {
|
||||||
|
val exerciseTypeId: Int
|
||||||
|
val exerciseName: String
|
||||||
|
val reps: Int?
|
||||||
|
|
||||||
|
data class Lift(
|
||||||
|
override val exerciseTypeId: Int,
|
||||||
|
override val exerciseName: String,
|
||||||
|
override val reps: Int,
|
||||||
|
val liftId: Int,
|
||||||
|
) : LiftStatusTarget
|
||||||
|
|
||||||
|
data class Set(
|
||||||
|
val setId: Int,
|
||||||
|
override val exerciseTypeId: Int,
|
||||||
|
override val exerciseName: String,
|
||||||
|
override val reps: Int?,
|
||||||
|
val summary: String,
|
||||||
|
val status: String,
|
||||||
|
val statusNote: String?,
|
||||||
|
) : LiftStatusTarget
|
||||||
|
}
|
||||||
|
|
||||||
|
val LIFT_STATUS_OPTIONS = listOf(
|
||||||
|
"training" to "Träning",
|
||||||
|
"competition" to "Tävling",
|
||||||
|
"excluded" to "Räknas ej",
|
||||||
|
)
|
||||||
|
|
||||||
|
fun liftStatusLabel(status: String): String =
|
||||||
|
LIFT_STATUS_OPTIONS.firstOrNull { it.first == status }?.second ?: status
|
||||||
|
|
||||||
|
fun liftStatusIcon(status: String): String = when (status) {
|
||||||
|
"competition" -> "🎖️"
|
||||||
|
"excluded" -> "⛔"
|
||||||
|
else -> ""
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun statusHelp(status: String) = when (status) {
|
||||||
|
"competition" -> "Tävlingslyft. Får egen rekordlista och räknas även bland alla giltiga."
|
||||||
|
"excluded" -> "Fusk, felregistrerat eller ej godkänt. Tas ur rekord, 1RM och PB-höjdpunkter – nästa lyft i topplistan blir rekord."
|
||||||
|
else -> "Vanligt träningslyft. Räknas i alla rekordlistor utom Tävling."
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun LiftStatusSheet(
|
||||||
|
target: LiftStatusTarget,
|
||||||
|
repo: GymRepository,
|
||||||
|
mode: String = "all",
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
onChanged: () -> Unit,
|
||||||
|
) {
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
var loading by remember { mutableStateOf(true) }
|
||||||
|
var saving by remember { mutableStateOf(false) }
|
||||||
|
var error by remember { mutableStateOf<String?>(null) }
|
||||||
|
var candidates by remember { mutableStateOf<List<LiftDetail>>(emptyList()) }
|
||||||
|
var focus by remember { mutableStateOf<LiftDetail?>(null) }
|
||||||
|
var status by remember { mutableStateOf((target as? LiftStatusTarget.Set)?.status ?: "training") }
|
||||||
|
var note by remember { mutableStateOf((target as? LiftStatusTarget.Set)?.statusNote ?: "") }
|
||||||
|
|
||||||
|
suspend fun load(keepId: Int? = null) {
|
||||||
|
loading = true
|
||||||
|
error = null
|
||||||
|
try {
|
||||||
|
val reps = target.reps
|
||||||
|
candidates = if (reps != null) repo.pbCandidates(target.exerciseTypeId, reps, mode) else emptyList()
|
||||||
|
focus = when (target) {
|
||||||
|
is LiftStatusTarget.Lift ->
|
||||||
|
candidates.firstOrNull { it.id == (keepId ?: target.liftId) } ?: repo.liftDetail(target.liftId)
|
||||||
|
is LiftStatusTarget.Set ->
|
||||||
|
candidates.firstOrNull { if (keepId != null) it.id == keepId else it.setId == target.setId }
|
||||||
|
}
|
||||||
|
val cur = focus
|
||||||
|
status = cur?.status ?: (target as? LiftStatusTarget.Set)?.status ?: "training"
|
||||||
|
note = cur?.statusNote ?: (target as? LiftStatusTarget.Set)?.statusNote ?: ""
|
||||||
|
} catch (e: Exception) {
|
||||||
|
error = e.userMessage("Kunde inte hämta lyftet — offline?")
|
||||||
|
} finally {
|
||||||
|
loading = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(target) { load() }
|
||||||
|
|
||||||
|
val current = focus
|
||||||
|
val dirty = status != (current?.status ?: (target as? LiftStatusTarget.Set)?.status ?: "training") ||
|
||||||
|
note.trim() != (current?.statusNote ?: (target as? LiftStatusTarget.Set)?.statusNote ?: "")
|
||||||
|
|
||||||
|
ModalBottomSheet(onDismissRequest = onDismiss) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.padding(horizontal = 20.dp).padding(bottom = 32.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
) {
|
||||||
|
val repsLabel = (current?.reps ?: target.reps)?.let { " · $it rep${if (it > 1) "s" else ""}" } ?: ""
|
||||||
|
Text("🏆 ${target.exerciseName}$repsLabel", style = MaterialTheme.typography.titleLarge)
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
Box(modifier = Modifier.fillMaxWidth().padding(24.dp), contentAlignment = Alignment.Center) {
|
||||||
|
CircularProgressIndicator()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
error?.let { Text(it, color = MaterialTheme.colorScheme.error) }
|
||||||
|
|
||||||
|
// ── Fokuserat lyft / set ──
|
||||||
|
Card(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||||
|
if (current != null) {
|
||||||
|
Row(verticalAlignment = Alignment.Bottom) {
|
||||||
|
Text(
|
||||||
|
"${current.weight.compact()} kg × ${current.reps}",
|
||||||
|
style = MaterialTheme.typography.headlineSmall,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
if (current.est1Rm > 0) {
|
||||||
|
Text("est. 1RM ${current.est1Rm.compact()} kg", style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
buildString {
|
||||||
|
append(current.date)
|
||||||
|
current.sessionName?.let { append(" · pass \"$it\"") } ?: append(" · löst lyft")
|
||||||
|
if (current.isCurrentRecord) append(" · 🏆 gällande rekord")
|
||||||
|
},
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
current.notes?.takeIf { it.isNotBlank() }?.let {
|
||||||
|
Text("📝 $it", style = MaterialTheme.typography.bodySmall)
|
||||||
|
}
|
||||||
|
} else if (target is LiftStatusTarget.Set) {
|
||||||
|
Text(target.summary, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.primary)
|
||||||
|
Text(
|
||||||
|
"Statusen sätts på setet och följer med lyftet när passet avslutas.",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Status ──
|
||||||
|
Text("Status", style = MaterialTheme.typography.labelLarge)
|
||||||
|
SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) {
|
||||||
|
LIFT_STATUS_OPTIONS.forEachIndexed { i, (key, label) ->
|
||||||
|
SegmentedButton(
|
||||||
|
selected = status == key,
|
||||||
|
onClick = { status = key },
|
||||||
|
shape = SegmentedButtonDefaults.itemShape(index = i, count = LIFT_STATUS_OPTIONS.size),
|
||||||
|
) { Text(label) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Text(statusHelp(status), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
|
OutlinedTextField(
|
||||||
|
value = note,
|
||||||
|
onValueChange = { if (it.length <= 300) note = it },
|
||||||
|
label = { Text("Kommentar (valfri)") },
|
||||||
|
placeholder = { Text(if (status == "excluded") "T.ex. ej godkänt djup, felregistrerat…" else "T.ex. SM 2026, PB-försök…") },
|
||||||
|
singleLine = true,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
scope.launch {
|
||||||
|
saving = true
|
||||||
|
try {
|
||||||
|
val n = note.trim().ifBlank { null }
|
||||||
|
val f = focus
|
||||||
|
if (f != null) repo.setLiftStatus(f.id, status, n)
|
||||||
|
else if (target is LiftStatusTarget.Set) repo.setSessionSetStatus(target.setId, status, n)
|
||||||
|
onChanged()
|
||||||
|
load(keepId = f?.id)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
error = e.userMessage("Kunde inte spara — offline?")
|
||||||
|
} finally {
|
||||||
|
saving = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
enabled = dirty && !saving,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) { Text(if (saving) "Sparar…" else "Spara") }
|
||||||
|
|
||||||
|
// ── Topplista ──
|
||||||
|
if (candidates.isNotEmpty()) {
|
||||||
|
Text(
|
||||||
|
"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),
|
||||||
|
)
|
||||||
|
candidates.forEach { c ->
|
||||||
|
val selected = c.id == current?.id
|
||||||
|
Card(
|
||||||
|
modifier = Modifier.fillMaxWidth().clickable {
|
||||||
|
focus = c
|
||||||
|
status = c.status
|
||||||
|
note = c.statusNote ?: ""
|
||||||
|
},
|
||||||
|
colors = CardDefaults.cardColors(
|
||||||
|
containerColor = if (selected) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surface,
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
"${c.weight.compact()} kg",
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = if (c.status == "excluded") MaterialTheme.colorScheme.onSurfaceVariant else MaterialTheme.colorScheme.primary,
|
||||||
|
)
|
||||||
|
Text(c.date, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
|
Text(
|
||||||
|
buildString {
|
||||||
|
if (c.isCurrentRecord) append("🏆 ")
|
||||||
|
append(liftStatusIcon(c.status)).append(" ").append(liftStatusLabel(c.status))
|
||||||
|
c.statusNote?.let { append(" · $it") }
|
||||||
|
}.trim(),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
maxLines = 1,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ import androidx.compose.foundation.lazy.LazyColumn
|
|||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.material3.Card
|
import androidx.compose.material3.Card
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
@@ -43,7 +44,11 @@ import eu.brassepc.fitnessdroid.data.SessionStats
|
|||||||
import eu.brassepc.fitnessdroid.ui.appContainer
|
import eu.brassepc.fitnessdroid.ui.appContainer
|
||||||
import eu.brassepc.fitnessdroid.ui.common.CalorieInfoDialog
|
import eu.brassepc.fitnessdroid.ui.common.CalorieInfoDialog
|
||||||
import eu.brassepc.fitnessdroid.ui.common.CalorieRow
|
import eu.brassepc.fitnessdroid.ui.common.CalorieRow
|
||||||
|
import eu.brassepc.fitnessdroid.ui.common.LiftStatusSheet
|
||||||
|
import eu.brassepc.fitnessdroid.ui.common.LiftStatusTarget
|
||||||
import eu.brassepc.fitnessdroid.ui.common.describe
|
import eu.brassepc.fitnessdroid.ui.common.describe
|
||||||
|
import eu.brassepc.fitnessdroid.ui.common.liftStatusIcon
|
||||||
|
import eu.brassepc.fitnessdroid.ui.common.liftStatusLabel
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
@@ -57,10 +62,17 @@ sealed interface DetailUiState {
|
|||||||
) : DetailUiState
|
) : DetailUiState
|
||||||
}
|
}
|
||||||
|
|
||||||
class HistoryDetailViewModel(private val repo: GymRepository) : ViewModel() {
|
class HistoryDetailViewModel(val repo: GymRepository) : ViewModel() {
|
||||||
val uiState = MutableStateFlow<DetailUiState>(DetailUiState.Loading)
|
val uiState = MutableStateFlow<DetailUiState>(DetailUiState.Loading)
|
||||||
private var loadedId: Int? = null
|
private var loadedId: Int? = null
|
||||||
|
|
||||||
|
/** Hämta om passet (efter t.ex. ändrad rekordstatus). */
|
||||||
|
fun reload() {
|
||||||
|
val id = loadedId ?: return
|
||||||
|
loadedId = null
|
||||||
|
load(id)
|
||||||
|
}
|
||||||
|
|
||||||
fun load(id: Int) {
|
fun load(id: Int) {
|
||||||
if (loadedId == id && uiState.value is DetailUiState.Ready) return
|
if (loadedId == id && uiState.value is DetailUiState.Ready) return
|
||||||
loadedId = id
|
loadedId = id
|
||||||
@@ -128,6 +140,16 @@ fun HistoryDetailScreen(
|
|||||||
val d = state.detail
|
val d = state.detail
|
||||||
val stats = state.stats
|
val stats = state.stats
|
||||||
var showCalorieInfo by remember { mutableStateOf(false) }
|
var showCalorieInfo by remember { mutableStateOf(false) }
|
||||||
|
var statusSheet by remember { mutableStateOf<LiftStatusTarget?>(null) }
|
||||||
|
|
||||||
|
statusSheet?.let { target ->
|
||||||
|
LiftStatusSheet(
|
||||||
|
target = target,
|
||||||
|
repo = viewModel.repo,
|
||||||
|
onDismiss = { statusSheet = null },
|
||||||
|
onChanged = { viewModel.reload() },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
if (showCalorieInfo && stats != null) {
|
if (showCalorieInfo && stats != null) {
|
||||||
CalorieInfoDialog(
|
CalorieInfoDialog(
|
||||||
@@ -215,7 +237,23 @@ fun HistoryDetailScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
exercise.sets.forEach { set ->
|
exercise.sets.forEach { set ->
|
||||||
Row {
|
// Tryck på ett set för rekordstatus (träning/tävling/räknas ej).
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clickable(enabled = set.id > 0) {
|
||||||
|
statusSheet = LiftStatusTarget.Set(
|
||||||
|
setId = set.id,
|
||||||
|
exerciseTypeId = exercise.exerciseTypeId,
|
||||||
|
exerciseName = exercise.name,
|
||||||
|
reps = set.reps,
|
||||||
|
summary = set.describe(),
|
||||||
|
status = set.status,
|
||||||
|
statusNote = set.statusNote,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.padding(vertical = 2.dp),
|
||||||
|
) {
|
||||||
Text(
|
Text(
|
||||||
"Set ${set.order}",
|
"Set ${set.order}",
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
@@ -225,7 +263,15 @@ fun HistoryDetailScreen(
|
|||||||
Text(
|
Text(
|
||||||
set.describe(),
|
set.describe(),
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
)
|
)
|
||||||
|
if (set.status != "training") {
|
||||||
|
Text(
|
||||||
|
"${liftStatusIcon(set.status)} ${liftStatusLabel(set.status)}",
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = if (set.status == "excluded") MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.tertiary,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (exercise.sets.isEmpty()) {
|
if (exercise.sets.isEmpty()) {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,8 +1,12 @@
|
|||||||
package eu.brassepc.fitnessdroid.ui.stats
|
package eu.brassepc.fitnessdroid.ui.stats
|
||||||
|
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.horizontalScroll
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.Favorite
|
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.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
@@ -38,13 +42,18 @@ import androidx.lifecycle.viewmodel.initializer
|
|||||||
import androidx.lifecycle.viewmodel.viewModelFactory
|
import androidx.lifecycle.viewmodel.viewModelFactory
|
||||||
import eu.brassepc.fitnessdroid.data.GymRepository
|
import eu.brassepc.fitnessdroid.data.GymRepository
|
||||||
import eu.brassepc.fitnessdroid.data.StatsBundle
|
import eu.brassepc.fitnessdroid.data.StatsBundle
|
||||||
|
import eu.brassepc.fitnessdroid.data.userMessage
|
||||||
import eu.brassepc.fitnessdroid.ui.appContainer
|
import eu.brassepc.fitnessdroid.ui.appContainer
|
||||||
|
import eu.brassepc.fitnessdroid.ui.common.LiftStatusSheet
|
||||||
|
import eu.brassepc.fitnessdroid.ui.common.LiftStatusTarget
|
||||||
import eu.brassepc.fitnessdroid.ui.common.compact
|
import eu.brassepc.fitnessdroid.ui.common.compact
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
data class PeriodOption(val id: String, val label: String)
|
data class PeriodOption(val id: String, val label: String)
|
||||||
|
|
||||||
|
val PB_MODES = listOf("all" to "Alla giltiga", "competition" to "Tävling", "training" to "Träning")
|
||||||
|
|
||||||
val PERIODS = listOf(
|
val PERIODS = listOf(
|
||||||
PeriodOption("week", "Vecka"),
|
PeriodOption("week", "Vecka"),
|
||||||
PeriodOption("month", "Månad"),
|
PeriodOption("month", "Månad"),
|
||||||
@@ -60,7 +69,7 @@ sealed interface StatsUiState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class StatsViewModel(
|
class StatsViewModel(
|
||||||
private val repo: GymRepository,
|
val repo: GymRepository,
|
||||||
private val gymApi: eu.brassepc.fitnessdroid.data.GymApi,
|
private val gymApi: eu.brassepc.fitnessdroid.data.GymApi,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
val period = MutableStateFlow("month")
|
val period = MutableStateFlow("month")
|
||||||
@@ -68,6 +77,19 @@ class StatsViewModel(
|
|||||||
val bodyMeasurements = MutableStateFlow<List<eu.brassepc.fitnessdroid.data.BodyMeasurement>>(emptyList())
|
val bodyMeasurements = MutableStateFlow<List<eu.brassepc.fitnessdroid.data.BodyMeasurement>>(emptyList())
|
||||||
val activities = MutableStateFlow<List<eu.brassepc.fitnessdroid.data.Activity>>(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? {
|
private fun periodStart(): java.time.Instant? {
|
||||||
val now = java.time.Instant.now()
|
val now = java.time.Instant.now()
|
||||||
return when (period.value) {
|
return when (period.value) {
|
||||||
@@ -120,6 +142,42 @@ class StatsViewModel(
|
|||||||
StatsUiState.Error("Kunde inte hämta statistiken — offline?")
|
StatsUiState.Error("Kunde inte hämta statistiken — offline?")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
refreshPbs()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Personbästa: hämtning ──
|
||||||
|
fun setPbMode(mode: String) {
|
||||||
|
if (pbMode.value == mode) return
|
||||||
|
pbMode.value = mode
|
||||||
|
refreshPbs()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun refreshPbs() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
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 {
|
companion object {
|
||||||
@@ -136,6 +194,22 @@ class StatsViewModel(
|
|||||||
fun StatsScreen(viewModel: StatsViewModel = viewModel(factory = StatsViewModel.Factory)) {
|
fun StatsScreen(viewModel: StatsViewModel = viewModel(factory = StatsViewModel.Factory)) {
|
||||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||||
val period by viewModel.period.collectAsStateWithLifecycle()
|
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 ->
|
||||||
|
LiftStatusSheet(
|
||||||
|
target = target,
|
||||||
|
repo = viewModel.repo,
|
||||||
|
mode = pbMode,
|
||||||
|
onDismiss = { pbSheet = null },
|
||||||
|
onChanged = { viewModel.refreshPbs() },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// Hämta om mätningarna varje gång fliken öppnas — ny vikt kan ha
|
// Hämta om mätningarna varje gång fliken öppnas — ny vikt kan ha
|
||||||
// lagts in via profilen sedan sist.
|
// lagts in via profilen sedan sist.
|
||||||
@@ -343,21 +417,100 @@ fun StatsScreen(viewModel: StatsViewModel = viewModel(factory = StatsViewModel.F
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (stats.personalBests.isNotEmpty()) {
|
|
||||||
item {
|
item {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(top = 6.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Text("Personbästa", style = MaterialTheme.typography.titleMedium, modifier = Modifier.weight(1f))
|
||||||
|
}
|
||||||
|
androidx.compose.foundation.lazy.LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.padding(top = 6.dp)) {
|
||||||
|
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(
|
Text(
|
||||||
"Personbästa",
|
"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.titleMedium,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
modifier = Modifier.padding(top = 6.dp),
|
modifier = Modifier.padding(top = 6.dp),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
items(stats.personalBests, key = { it.exerciseName }) { pb ->
|
pbError?.let {
|
||||||
Card(modifier = Modifier.fillMaxWidth()) {
|
|
||||||
Column(modifier = Modifier.padding(14.dp)) {
|
|
||||||
Text(pb.exerciseName, style = MaterialTheme.typography.titleSmall)
|
|
||||||
Text(
|
Text(
|
||||||
pb.records.joinToString(" ") { "${it.reps}RM ${it.weight.compact()}kg" },
|
it,
|
||||||
style = MaterialTheme.typography.bodySmall,
|
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(
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
items(pbs, key = { "${it.exerciseTypeId}-${it.exerciseName}" }) { pb ->
|
||||||
|
Card(modifier = Modifier.fillMaxWidth()) {
|
||||||
|
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)
|
||||||
|
.horizontalScroll(rememberScrollState()),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||||
|
) {
|
||||||
|
pb.records.forEach { r ->
|
||||||
|
androidx.compose.material3.AssistChip(
|
||||||
|
onClick = {
|
||||||
|
// 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 ""}")
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
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,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
modifier = Modifier.padding(top = 4.dp),
|
modifier = Modifier.padding(top = 4.dp),
|
||||||
)
|
)
|
||||||
@@ -367,7 +520,6 @@ fun StatsScreen(viewModel: StatsViewModel = viewModel(factory = StatsViewModel.F
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private data class CompPart(val label: String, val value: (eu.brassepc.fitnessdroid.data.BodyMeasurement) -> Double?)
|
private data class CompPart(val label: String, val value: (eu.brassepc.fitnessdroid.data.BodyMeasurement) -> Double?)
|
||||||
@@ -498,9 +650,19 @@ private fun TrendBars(stats: StatsBundle) {
|
|||||||
val max = stats.trend.maxOf { it.volumeKg }.takeIf { it > 0 } ?: 1.0
|
val max = stats.trend.maxOf { it.volumeKg }.takeIf { it > 0 } ?: 1.0
|
||||||
val listState = androidx.compose.foundation.lazy.rememberLazyListState()
|
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) {
|
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(
|
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