Compare commits

25 Commits
latest ... main

Author SHA1 Message Date
efebcb111f doc: lägg till API-ändring (spara längd + ålder/kön) i openScale-planen
Some checks failed
release / build-release (push) Failing after 3h0m33s
Längd på servern -> korrektare BIA (impedansvågar) och BMR-baserad
energiförbrukning. Egen milstolpe + backend/app/webb-detaljer.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Kf4MAbZ3c3B4Xy5XfuGsCP
2026-07-26 11:35:56 +02:00
a99c11524b doc: plan för openScale BT-vågsintegration (GPL-relicens, alla drivrutiner)
All checks were successful
release / build-release (push) Successful in 6m30s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Kf4MAbZ3c3B4Xy5XfuGsCP
2026-07-26 11:24:19 +02:00
b33a849f23 Statistik: kroppssammansättnings-graf med %/kg-växel + omräknad vikt
All checks were successful
release / build-release (push) Successful in 5m24s
Ny sammansättningskort i statistiken: senaste muskel/fett/vatten i % med
omräkning till faktisk vikt (vikt × % / 100), plus flerlinjes-graf över
tid som växlar mellan procent och kg. Version 0.3.2.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Kf4MAbZ3c3B4Xy5XfuGsCP
2026-07-26 00:10:04 +02:00
7c21428a28 Kroppssammansättning: muskel/fett/vatten-% i vikt-dialogen + historiken
All checks were successful
release / build-release (push) Successful in 5m21s
Valfria fält vid ny mätning och rättning; visas i historiklistan.
Tomt fält vid rättning rensar värdet på servern (-1-konventionen).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Kf4MAbZ3c3B4Xy5XfuGsCP
2026-07-25 18:30:00 +02:00
7a4995823e doc: milstolpe 3 komplett i planen
Some checks failed
release / build-release (push) Failing after 36m56s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Kf4MAbZ3c3B4Xy5XfuGsCP
2026-07-25 18:20:35 +02:00
143abee871 Kroppsvikt: datumväljare i dialogen, statistiken laddar om, graf-fix samma dag
All checks were successful
release / build-release (push) Successful in 5m15s
- Vikt-dialogen har datumknapp (kalender) för både ny mätning och rättning —
  mätningar kan flyttas till rätt dag (skickas som 12:00 UTC så datumet
  inte glider över tidszoner)
- Statistikfliken hämtar om mätningarna varje gång den öppnas
- Grafen sprider mätningar inom samma dygn på index istället för att
  kollapsa till en punkt

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Kf4MAbZ3c3B4Xy5XfuGsCP
2026-07-25 18:14:14 +02:00
e2ffb4f011 Helnamn i hälsningen, flera egna stänger, kroppsvikt med historik och graf
All checks were successful
release / build-release (push) Successful in 5m16s
- Hälsningen använder profilens displayName (cachas lokalt)
- Skivkalkylatorn: obegränsat antal egna stänger med exakt vikt (13,7 kg
  funkar) — läggs till/tas bort i inställningarna, visas som chips
- Profil: uppdatera kroppsvikten direkt i appen (decimalinmatning);
  vikthistorik med uuid-baserad rättning och borttagning per mätning
  (addBodyMeasurement med fallback till updateBodyWeight mot äldre API)
- Statistik: kroppsviktsgraf med råpunkter, glidande medelvärde (mjuk
  linje) och streckad linjär trendlinje
- API:t har redan fälten muskel/fett/vatten-% för framtida kroppsanalys

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Kf4MAbZ3c3B4Xy5XfuGsCP
2026-07-25 17:51:38 +02:00
d577a92eec Skivkalkylator: tryck på vikten i aktiva setet
All checks were successful
release / build-release (push) Successful in 5m35s
- Ark med skivorna per sida i standardfärger (25 röd, 20 blå, 15 gul,
  10 grön, 5 vit, 2,5 svart, 1,25 grå), störst innerst mot låskragen
- Stångval: 20 kg / utan stång / egen vikt (sätts i inställningarna,
  standard 15 kg)
- Textsammanfattning ('Per sida: 20 + 10 + 2,5 kg') och tydlig hint om
  vikten inte går att lägga exakt med standardskivor

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Kf4MAbZ3c3B4Xy5XfuGsCP
2026-07-25 17:06:10 +02:00
bb3502ed26 Passhistoriken visar passlängden i både listan och detaljvyn
All checks were successful
release / build-release (push) Successful in 5m43s
Manuell tidsrättning (durationSecondsOverride) vinner, annars
slut − start. Format: '47 min' eller '1h 12m'.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Kf4MAbZ3c3B4Xy5XfuGsCP
2026-07-25 17:00:23 +02:00
34c2a6c4da Statistik: scrollbar trendgraf med läsbara etiketter + Totalt-period; auth-luckor
All checks were successful
release / build-release (push) Successful in 7m34s
- Trendstaplarna i en horisontellt scrollbar rad (38dp per stapel) med
  volymsiffra ovanför och hel etikett under — startar på senaste perioden.
  Fixar etikettröran på månad/halvår
- Ny period 'Totalt' (period=all, stöds redan av API:t; delta saknas per design)
- Tyst återinloggning provas nu även när tokens saknas helt och när JWT:n
  hunnit gå ut vid nätfel under appstart
- Inställningstext: förtydligar att auto-återinloggning aktiveras vid
  nästa inloggning (uppgifter sparas bara vid login)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Kf4MAbZ3c3B4Xy5XfuGsCP
2026-07-25 16:42:45 +02:00
df4d122562 In-app-uppdatering: koll mot version.json, nedladdning och installation
All checks were successful
release / build-release (push) Successful in 5m26s
- CI publicerar version.json (versionCode/versionName ur APK:n, sha256,
  apkUrl) som release-asset
- Appen jämför versionCode vid start; banner på hemskärmen med
  nedladdningsprogress; sha256-verifiering; Androids installationsdialog
  via FileProvider (samma signatur -> uppdaterar rakt över)
- REQUEST_INSTALL_PACKAGES + FileProvider i manifestet

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Kf4MAbZ3c3B4Xy5XfuGsCP
2026-07-25 13:08:07 +02:00
7a04893294 M3: statistik, kroppskarta, PB-lista, tyst återinloggning, fler inställningar
Some checks failed
release / build-release (push) Has been cancelled
- Statistikfliken: periodväljare (vecka/månad/halvår/år), KPI-kort med
  delta mot förra perioden, volymtrend, kroppskarta fram/bak med värme
  per muskelgrupp (Canvas), höjdpunkter och PB per övning/rep-antal
- Tyst återinloggning: uppgifter sparas AES/GCM-krypterat via Android
  Keystore; när refresh avvisas provas nytt login innan utloggning —
  'utloggad utan att appen märker det' försvinner. Manuell utloggning
  rensar uppgifterna; toggle i inställningarna (på som standard)
- Inställningar: viktsteg för steppers (1,25/2,5/5 kg), API-url,
  auto-återinloggning
- Version 0.3.0

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Kf4MAbZ3c3B4Xy5XfuGsCP
2026-07-25 13:03:24 +02:00
31d05039f0 Avsluta-överblicken: skriv ut kg i set-raderna (60kg×6)
All checks were successful
release / build-release (push) Successful in 4m53s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Kf4MAbZ3c3B4Xy5XfuGsCP
2026-07-25 12:35:43 +02:00
0812759719 doc: polerings-rundan för milstolpe 2 i planen
All checks were successful
release / build-release (push) Successful in 4m41s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Kf4MAbZ3c3B4Xy5XfuGsCP
2026-07-25 12:03:16 +02:00
493baee88d Avsluta-överblicken: övningslista med loggade set och PB-markering
All checks were successful
release / build-release (push) Successful in 4m52s
Varje övning listas med sina set (vikt×reps m.m.) och set som slår
cachade personbästat för samma rep-antal märks med trofé — även mot
tidigare set i samma pass. Personbästa cachas från API:t vid refresh
(cached_pb, Room v4).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Kf4MAbZ3c3B4Xy5XfuGsCP
2026-07-25 11:57:25 +02:00
27331b20a7 Avsluta-dialogen: tidsredigeringen hopfälld bakom 'Ändra tid'
All checks were successful
release / build-release (push) Successful in 4m46s
Standardvyn visar bara en rad: start → slut (varaktighet). Knappen
fäller ut start/varaktighet/slut-kontrollerna vid behov.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Kf4MAbZ3c3B4Xy5XfuGsCP
2026-07-25 11:14:42 +02:00
983886e8f1 Avsluta-dialogen: varaktighet och sluttid som pickers istället för steppers
Some checks failed
release / build-release (push) Has been cancelled
±-knapparna fick inte plats på smala skärmar (Oukitel WP9) och var
klyddiga. Nu: två breda knappar — Varaktighet ('hur länge gymmade du?')
och Slut (klockslag, rullar över till nästa dag om det är före starten).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Kf4MAbZ3c3B4Xy5XfuGsCP
2026-07-25 11:13:10 +02:00
8928a4edb4 Avsluta-dialogen: redigerbar start (datum + klockslag) och varaktighet
All checks were successful
release / build-release (push) Successful in 4m58s
- Startdatum/-tid väljs med date-/timepicker; varaktigheten med steppers,
  och den beräknade sluttiden visas — täcker både 'startade passet mitt i
  träningen' och 'glömde avsluta till dagen efter'
- Förifylld varaktighet ankras i senast loggade setet istället för 'nu',
  så ett bortglömt pass inte föreslår 14 timmar
- Rättad start synkas som updateGymSession(date) före completeGymSession
- Room v3-migration för editedStartEpochMs

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Kf4MAbZ3c3B4Xy5XfuGsCP
2026-07-25 10:49:53 +02:00
214e12d2e2 Avsluta-överblick med redigerbar passtid + kcal-summering med uträkning
All checks were successful
release / build-release (push) Successful in 5m37s
- Avsluta-dialogen visar övningar/set/reps/volym, passtiden går att justera
  (±1/±5 min, skickas som durationSecondsOverride — för alla gånger man
  glömt avsluta) och en kcal-uppskattning som räknas om live med tiden
- 'Hur räknas detta?' visar MET-formeln (MET × kroppsvikt × timmar) med
  siffrorna per övning; kroppsvikten cachas från profilen
- Passdetaljen i historiken: summeringskort (tid, set, volym, kcal) +
  samma förklaringsdialog med serverns faktiska fördelning, och kcal
  per övning på varje övningskort
- Room v2-migration för durationSecondsOverride

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Kf4MAbZ3c3B4Xy5XfuGsCP
2026-07-25 10:13:10 +02:00
68dd0135ab Passdetaljer i historiken + övningshistorik med tyngsta lyft (3 mån)
All checks were successful
release / build-release (push) Successful in 4m48s
- Tryck på ett pass i historiken -> detaljvy med övningar och alla set
- Historik-ikon på övningssidan i passläget -> ark med två flikar:
  tidigare pass med övningen (set för set) och tyngsta viktade seten
  senaste tre månaderna (uppvärmningsset exkluderade)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Kf4MAbZ3c3B4Xy5XfuGsCP
2026-07-24 21:44:49 +02:00
0f62741043 Rätta loggade set: tryck på ett klart set för att ändra eller ta bort
All checks were successful
release / build-release (push) Successful in 4m46s
Samma steppers som vid loggning. Ändring på osynkat set bärs av den
väntande add-opsen; synkat set får en update-op. Borttagning: lokalt
direkt, remove-op mot servern när setet redan var synkat.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Kf4MAbZ3c3B4Xy5XfuGsCP
2026-07-24 18:39:29 +02:00
93a1aa249a Robust referensdata-hämtning + auto-retry i övningsväljaren
All checks were successful
release / build-release (push) Successful in 4m46s
Om servern saknar de nya fälten (iconKey/defaultRestSeconds) faller
queries tillbaka på den äldre formen istället för att lämna cachen tom.
Varje cachesektion hämtas oberoende. Väljaren triggar en ny hämtning när
cachen är tom och har en Uppdatera-knapp i tomläget.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Kf4MAbZ3c3B4Xy5XfuGsCP
2026-07-24 18:21:24 +02:00
2c17873ca4 doc: milstolpe 2 klar i planen
All checks were successful
release / build-release (push) Successful in 8m50s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Kf4MAbZ3c3B4Xy5XfuGsCP
2026-07-24 13:31:47 +02:00
984e2374f8 M2: passläge, offline-först med synk-kö, vilotimer med inställningar
Some checks failed
release / build-release (push) Has been cancelled
- Room-cache: övningar/muskelgrupper/favoritkort + lokal-först-pass
- SyncEngine: FIFO-op-kö mot API:t, retry med backoff, synk-status per set
  (moln-ikon) + global räknare; nätfel loggar inte ut användaren
- Passläge: en övning i taget (svep), ±steppers, RPE, förifyllda värden
  från förra passet, logga set med ett tryck
- Vilotimer: helskärm eller banner (inställning), larm/vibration/visuell/
  inget vid utgång, ±15s, per-övnings vilotid via API:ts defaultRestSeconds
- Övningsväljare: sök + muskelgrupp/muskel-chips med ikoner (API:ts iconKey
  med namnheuristik som fallback), favoriter först, KG/REPS/TID/M-brickor
- Navigation: bottenrad + start-FAB + minispelare, hemskärm med favoritpass
- Inställningsskärm under Profil; historik-lista; version 0.2.0

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Kf4MAbZ3c3B4Xy5XfuGsCP
2026-07-24 13:29:17 +02:00
53d17d467f doc: designförslag för milstolpe 2 (passpilot-konceptet)
All checks were successful
release / build-release (push) Successful in 4m31s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Kf4MAbZ3c3B4Xy5XfuGsCP
2026-07-24 12:03:43 +02:00
43 changed files with 7307 additions and 118 deletions

View File

@@ -90,6 +90,18 @@ jobs:
mkdir -p build mkdir -p build
cp app/build/outputs/apk/release/app-release.apk build/FitnessDroid.apk cp app/build/outputs/apk/release/app-release.apk build/FitnessDroid.apk
(cd build && sha256sum FitnessDroid.apk > checksums.txt && ls -la) (cd build && sha256sum FitnessDroid.apk > checksums.txt && ls -la)
# version.json för appens inbyggda uppdateringskoll
VN=$("$HOME/ci-tools/build-tools/aapt2" dump badging build/FitnessDroid.apk \
| sed -n "s/.*versionName='\([^']*\)'.*/\1/p" | head -1)
SHA=$(cut -d' ' -f1 build/checksums.txt)
jq -n \
--argjson code "${{ github.run_number }}" \
--arg name "$VN" \
--arg sha "$SHA" \
--arg url "https://gitea.brasse-pc.eu/${{ github.repository }}/releases/download/latest/FitnessDroid.apk" \
'{versionCode:$code, versionName:$name, sha256:$sha, apkUrl:$url}' \
> build/version.json
cat build/version.json
- name: Skapa/uppdatera latest-release + ladda upp APK - name: Skapa/uppdatera latest-release + ladda upp APK
env: env:
@@ -124,7 +136,7 @@ jobs:
-d "$(jq -n --arg body "$BODY" '{body:$body}')" -o /dev/null -d "$(jq -n --arg body "$BODY" '{body:$body}')" -o /dev/null
fi fi
echo "release id: $rid" echo "release id: $rid"
for f in FitnessDroid.apk checksums.txt; do for f in FitnessDroid.apk checksums.txt version.json; do
aid=$(curl -s "$API/repos/$REPO/releases/$rid/assets" \ aid=$(curl -s "$API/repos/$REPO/releases/$rid/assets" \
-H "Authorization: token $TOKEN" | jq -r ".[] | select(.name==\"$f\") | .id") -H "Authorization: token $TOKEN" | jq -r ".[] | select(.name==\"$f\") | .id")
if [ -n "$aid" ] && [ "$aid" != "null" ]; then if [ -n "$aid" ] && [ "$aid" != "null" ]; then

View File

@@ -5,6 +5,7 @@ plugins {
alias(libs.plugins.kotlin.android) alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.compose) alias(libs.plugins.kotlin.compose)
alias(libs.plugins.kotlin.serialization) alias(libs.plugins.kotlin.serialization)
alias(libs.plugins.ksp)
} }
// Signeringsnyckeln är inte incheckad. Lokalt ligger den i signing/ (gitignorerad), // Signeringsnyckeln är inte incheckad. Lokalt ligger den i signing/ (gitignorerad),
@@ -28,7 +29,7 @@ android {
minSdk = 31 minSdk = 31
targetSdk = 35 targetSdk = 35
versionCode = ciRunNumber ?: 1 versionCode = ciRunNumber ?: 1
versionName = "0.1.0" + (ciRunNumber?.let { "+build.$it" } ?: "-dev") versionName = "0.3.2" + (ciRunNumber?.let { "+build.$it" } ?: "-dev")
} }
signingConfigs { signingConfigs {
@@ -80,5 +81,8 @@ dependencies {
implementation(libs.androidx.datastore.preferences) implementation(libs.androidx.datastore.preferences)
implementation(libs.okhttp) implementation(libs.okhttp)
implementation(libs.kotlinx.serialization.json) implementation(libs.kotlinx.serialization.json)
implementation(libs.androidx.room.runtime)
implementation(libs.androidx.room.ktx)
ksp(libs.androidx.room.compiler)
debugImplementation(libs.androidx.compose.ui.tooling) debugImplementation(libs.androidx.compose.ui.tooling)
} }

View File

@@ -2,6 +2,8 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"> <manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.VIBRATE" />
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<application <application
android:name=".FitnessDroidApplication" android:name=".FitnessDroidApplication"
@@ -10,6 +12,16 @@
android:supportsRtl="true" android:supportsRtl="true"
android:theme="@style/Theme.FitnessDroid"> android:theme="@style/Theme.FitnessDroid">
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
<activity <activity
android:name=".MainActivity" android:name=".MainActivity"
android:exported="true" android:exported="true"

View File

@@ -3,16 +3,34 @@ package eu.brassepc.fitnessdroid
import android.app.Application import android.app.Application
import android.content.Context import android.content.Context
import eu.brassepc.fitnessdroid.data.AuthRepository import eu.brassepc.fitnessdroid.data.AuthRepository
import eu.brassepc.fitnessdroid.data.CredentialStore
import eu.brassepc.fitnessdroid.data.GraphQlClient import eu.brassepc.fitnessdroid.data.GraphQlClient
import eu.brassepc.fitnessdroid.data.GymApi import eu.brassepc.fitnessdroid.data.GymApi
import eu.brassepc.fitnessdroid.data.GymRepository
import eu.brassepc.fitnessdroid.data.RestTimerController
import eu.brassepc.fitnessdroid.data.SettingsStore
import eu.brassepc.fitnessdroid.data.SyncEngine
import eu.brassepc.fitnessdroid.data.TokenStore import eu.brassepc.fitnessdroid.data.TokenStore
import eu.brassepc.fitnessdroid.data.UpdateChecker
import eu.brassepc.fitnessdroid.data.local.AppDatabase
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
/** Enkel manuell DI-container — appen är för liten för Hilt. */ /** Enkel manuell DI-container — appen är för liten för Hilt. */
class AppContainer(context: Context) { class AppContainer(context: Context) {
val appScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
val tokenStore = TokenStore(context) val tokenStore = TokenStore(context)
val settingsStore = SettingsStore(context)
val credentialStore = CredentialStore(context)
val graphQlClient = GraphQlClient { tokenStore.apiUrl() } val graphQlClient = GraphQlClient { tokenStore.apiUrl() }
val authRepository = AuthRepository(tokenStore, graphQlClient) val authRepository = AuthRepository(tokenStore, graphQlClient, credentialStore, settingsStore)
val gymApi = GymApi(graphQlClient, authRepository) val gymApi = GymApi(graphQlClient, authRepository)
val database = AppDatabase.build(context)
val syncEngine = SyncEngine(database, graphQlClient, authRepository, appScope)
val gymRepository = GymRepository(database, graphQlClient, authRepository, syncEngine, tokenStore, appScope)
val restTimer = RestTimerController(context, settingsStore, appScope)
val updateChecker = UpdateChecker(context)
} }
class FitnessDroidApplication : Application() { class FitnessDroidApplication : Application() {

View File

@@ -2,6 +2,7 @@ package eu.brassepc.fitnessdroid.data
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withLock
import kotlinx.serialization.json.booleanOrNull import kotlinx.serialization.json.booleanOrNull
@@ -26,6 +27,8 @@ sealed interface AuthState {
class AuthRepository( class AuthRepository(
private val store: TokenStore, private val store: TokenStore,
private val client: GraphQlClient, private val client: GraphQlClient,
private val credentials: CredentialStore,
private val settings: SettingsStore,
) { ) {
private val _state = MutableStateFlow<AuthState>(AuthState.Restoring) private val _state = MutableStateFlow<AuthState>(AuthState.Restoring)
val state: StateFlow<AuthState> = _state val state: StateFlow<AuthState> = _state
@@ -59,14 +62,40 @@ class AuthRepository(
refreshToken = refreshToken, refreshToken = refreshToken,
expirationEpochMs = parseExpiration(expiration), expirationEpochMs = parseExpiration(expiration),
) )
// Spara uppgifterna krypterat för tyst återinloggning (inställbart).
if (settings.settings.first().autoRelogin) {
runCatching { credentials.save(username.trim(), password) }
}
_state.value = AuthState.LoggedIn(userName) _state.value = AuthState.LoggedIn(userName)
} }
/**
* Servern har avvisat vår session (refresh-token död, session utgången).
* Sista utvägen innan riktig utloggning: logga in tyst igen med de
* sparade uppgifterna. Returnerar ny JWT eller null.
*/
private suspend fun trySilentRelogin(): String? {
if (!settings.settings.first().autoRelogin) return null
val creds = credentials.load() ?: return null
return try {
login(creds.username, creds.password, store.apiUrl())
store.read().token
} catch (e: GraphQlException) {
// Fel lösenord/användare — uppgifterna är inte giltiga längre.
runCatching { credentials.clear() }
null
}
// IOException bubblar uppåt: nätfel ska ge retry, inte utloggning.
}
/** Återställ sessionen vid appstart, som webbens restoreSession(). */ /** Återställ sessionen vid appstart, som webbens restoreSession(). */
suspend fun restoreSession() { suspend fun restoreSession() {
val saved = store.read() val saved = store.read()
if (saved.sessionId.isNullOrEmpty() || saved.refreshToken.isNullOrEmpty()) { if (saved.sessionId.isNullOrEmpty() || saved.refreshToken.isNullOrEmpty()) {
_state.value = AuthState.LoggedOut // Inga tokens alls — men kanske sparade uppgifter (t.ex. efter
// att servern rensat sessioner). Prova tyst innan login-skärmen.
val relogged = runCatching { trySilentRelogin() }.getOrNull()
if (relogged == null) _state.value = AuthState.LoggedOut
return return
} }
try { try {
@@ -78,18 +107,22 @@ class AuthRepository(
?.get("isValid")?.jsonPrimitive?.booleanOrNull == true ?.get("isValid")?.jsonPrimitive?.booleanOrNull == true
if (valid) { if (valid) {
_state.value = AuthState.LoggedIn(saved.username.orEmpty()) _state.value = AuthState.LoggedIn(saved.username.orEmpty())
} else if (trySilentRelogin() != null) {
// login() har redan satt LoggedIn — utloggningen märktes aldrig.
} else { } else {
store.clearSession() store.clearSession()
_state.value = AuthState.LoggedOut _state.value = AuthState.LoggedOut
} }
} catch (e: Exception) { } catch (e: Exception) {
// Nätverksfel vid uppstart: släpp in användaren om JWT:n inte hunnit // Nätverksfel vid uppstart: släpp in användaren om JWT:n inte hunnit
// gå ut, annars visa inloggningen igen. // gå ut. Har den gått ut: prova tyst återinloggning (kan också
// faila på nätet — då login-skärmen, synken tar det sen).
val stillValid = (saved.expirationEpochMs ?: 0) > System.currentTimeMillis() val stillValid = (saved.expirationEpochMs ?: 0) > System.currentTimeMillis()
_state.value = if (stillValid) { _state.value = when {
AuthState.LoggedIn(saved.username.orEmpty()) stillValid -> AuthState.LoggedIn(saved.username.orEmpty())
} else { runCatching { trySilentRelogin() }.getOrNull() != null ->
AuthState.LoggedOut AuthState.LoggedIn(store.read().username.orEmpty())
else -> AuthState.LoggedOut
} }
} }
} }
@@ -110,6 +143,7 @@ class AuthRepository(
val sessionId = saved.sessionId val sessionId = saved.sessionId
val refreshToken = saved.refreshToken val refreshToken = saved.refreshToken
if (sessionId == null || refreshToken == null) { if (sessionId == null || refreshToken == null) {
trySilentRelogin()?.let { return it }
forceLogout() forceLogout()
throw GraphQlException(listOf("Sessionen har gått ut, logga in igen")) throw GraphQlException(listOf("Sessionen har gått ut, logga in igen"))
} }
@@ -127,12 +161,17 @@ class AuthRepository(
val newRefresh = result?.get("newRefreshToken")?.jsonPrimitive?.contentOrNullSafe() val newRefresh = result?.get("newRefreshToken")?.jsonPrimitive?.contentOrNullSafe()
val newExpiration = result?.get("accessTokenExpiration")?.jsonPrimitive?.contentOrNullSafe() val newExpiration = result?.get("accessTokenExpiration")?.jsonPrimitive?.contentOrNullSafe()
if (!success || newToken == null || newRefresh == null) { if (!success || newToken == null || newRefresh == null) {
trySilentRelogin()?.let { return it }
forceLogout() forceLogout()
throw GraphQlException(listOf("Sessionen har gått ut, logga in igen")) throw GraphQlException(listOf("Sessionen har gått ut, logga in igen"))
} }
store.updateTokens(newToken, newRefresh, parseExpiration(newExpiration)) store.updateTokens(newToken, newRefresh, parseExpiration(newExpiration))
newToken newToken
} catch (e: GraphQlException) { } catch (e: GraphQlException) {
// Servern avvisade refresh-tokenen. Försök logga in tyst igen med
// sparade uppgifter innan riktig utloggning. Nätfel (IOException)
// bubblar istället uppåt så synkmotorn kan försöka igen.
trySilentRelogin()?.let { return it }
forceLogout() forceLogout()
throw GraphQlException(listOf("Sessionen har gått ut, logga in igen")) throw GraphQlException(listOf("Sessionen har gått ut, logga in igen"))
} }
@@ -145,6 +184,8 @@ class AuthRepository(
client.execute(LOGOUT, buildJsonObject { put("s", saved.sessionId) }) client.execute(LOGOUT, buildJsonObject { put("s", saved.sessionId) })
} }
} }
// Manuell utloggning = släng även de sparade uppgifterna.
runCatching { credentials.clear() }
forceLogout() forceLogout()
} }

View File

@@ -0,0 +1,85 @@
package eu.brassepc.fitnessdroid.data
import android.content.Context
import android.security.keystore.KeyGenParameterSpec
import android.security.keystore.KeyProperties
import android.util.Base64
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import kotlinx.coroutines.flow.first
import java.security.KeyStore
import javax.crypto.Cipher
import javax.crypto.KeyGenerator
import javax.crypto.SecretKey
import javax.crypto.spec.GCMParameterSpec
private val Context.credDataStore by preferencesDataStore(name = "credentials")
/**
* Sparar inloggningsuppgifterna för tyst återinloggning. Lösenordet
* AES/GCM-krypteras med en nyckel i Android Keystore — nyckeln lämnar
* aldrig enheten och ciphertexten är värdelös utan den.
*/
class CredentialStore(private val context: Context) {
data class Credentials(val username: String, val password: String)
suspend fun save(username: String, password: String) {
val cipher = Cipher.getInstance(TRANSFORM)
cipher.init(Cipher.ENCRYPT_MODE, key())
val ct = cipher.doFinal(password.toByteArray(Charsets.UTF_8))
val blob = Base64.encodeToString(cipher.iv, Base64.NO_WRAP) + ":" +
Base64.encodeToString(ct, Base64.NO_WRAP)
context.credDataStore.edit {
it[KEY_USER] = username
it[KEY_PW] = blob
}
}
suspend fun load(): Credentials? {
val prefs = context.credDataStore.data.first()
val user = prefs[KEY_USER] ?: return null
val blob = prefs[KEY_PW] ?: return null
return runCatching {
val (ivB64, ctB64) = blob.split(":", limit = 2)
val cipher = Cipher.getInstance(TRANSFORM)
cipher.init(
Cipher.DECRYPT_MODE,
key(),
GCMParameterSpec(128, Base64.decode(ivB64, Base64.NO_WRAP)),
)
Credentials(
user,
cipher.doFinal(Base64.decode(ctB64, Base64.NO_WRAP)).toString(Charsets.UTF_8),
)
}.getOrNull()
}
suspend fun clear() {
context.credDataStore.edit { it.clear() }
}
private fun key(): SecretKey {
val ks = KeyStore.getInstance("AndroidKeyStore").apply { load(null) }
(ks.getKey(KEY_ALIAS, null) as? SecretKey)?.let { return it }
val generator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore")
generator.init(
KeyGenParameterSpec.Builder(
KEY_ALIAS,
KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT,
)
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
.build()
)
return generator.generateKey()
}
companion object {
private const val KEY_ALIAS = "fitnessdroid-credentials"
private const val TRANSFORM = "AES/GCM/NoPadding"
private val KEY_USER = stringPreferencesKey("username")
private val KEY_PW = stringPreferencesKey("password_encrypted")
}
}

View File

@@ -47,6 +47,11 @@ class GraphQlClient(private val urlProvider: suspend () -> String) {
http.newCall(request).execute().use { response -> http.newCall(request).execute().use { response ->
val text = response.body?.string().orEmpty() val text = response.body?.string().orEmpty()
// 5xx = servern/proxyn nere — behandla som nätfel (retry) och inte
// som ett logiskt API-fel (som t.ex. loggar ut användaren).
if (response.code >= 500) {
throw java.io.IOException("HTTP ${response.code} från servern")
}
val root = runCatching { json.parseToJsonElement(text).jsonObject }.getOrNull() val root = runCatching { json.parseToJsonElement(text).jsonObject }.getOrNull()
?: throw GraphQlException(listOf("HTTP ${response.code}: oväntat svar från servern")) ?: throw GraphQlException(listOf("HTTP ${response.code}: oväntat svar från servern"))

View File

@@ -0,0 +1,857 @@
package eu.brassepc.fitnessdroid.data
import eu.brassepc.fitnessdroid.data.local.AppDatabase
import eu.brassepc.fitnessdroid.data.local.CachedExerciseType
import eu.brassepc.fitnessdroid.data.local.CachedMuscle
import eu.brassepc.fitnessdroid.data.local.CachedMuscleGroup
import eu.brassepc.fitnessdroid.data.local.CachedStartCard
import eu.brassepc.fitnessdroid.data.local.LocalExercise
import eu.brassepc.fitnessdroid.data.local.LocalSession
import eu.brassepc.fitnessdroid.data.local.LocalSet
import eu.brassepc.fitnessdroid.data.local.PendingOp
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import kotlinx.serialization.json.booleanOrNull
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.doubleOrNull
import kotlinx.serialization.json.int
import kotlinx.serialization.json.intOrNull
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import kotlinx.serialization.json.put
/**
* 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.
*/
class GymRepository(
private val db: AppDatabase,
private val client: GraphQlClient,
private val auth: AuthRepository,
private val sync: SyncEngine,
private val store: TokenStore,
private val scope: CoroutineScope,
) {
val activeSession = db.sessionDao().activeSession()
val exerciseTypes = db.cacheDao().exerciseTypes()
val muscleGroups = db.cacheDao().muscleGroups()
val muscles = db.cacheDao().muscles()
val startCards = db.cacheDao().startCards()
val pendingCount = db.opDao().pendingCount()
val syncStatus = sync.status
fun exercises(sessionId: Long) = db.sessionDao().exercises(sessionId)
fun setsForSession(sessionId: Long) = db.sessionDao().setsForSession(sessionId)
private suspend fun enqueue(kind: String, targetLocalId: Long) {
db.opDao().enqueue(
PendingOp(kind = kind, targetLocalId = targetLocalId, createdAtEpochMs = System.currentTimeMillis())
)
sync.kick()
}
/* ---------- Passhantering ---------- */
suspend fun startFreeSession(name: String? = null): Long {
val id = db.sessionDao().insertSession(
LocalSession(name = name, startedAtEpochMs = System.currentTimeMillis())
)
enqueue(OpKind.START_SESSION, id)
return id
}
/** Starta från favoritpass/mall-kort: skapar passet + alla övningar lokalt. */
suspend fun startFromCard(card: CachedStartCard): Long {
val sessionId = db.sessionDao().insertSession(
LocalSession(name = card.title, startedAtEpochMs = System.currentTimeMillis())
)
enqueue(OpKind.START_SESSION, sessionId)
card.exerciseTypeIds.split(",").mapNotNull { it.trim().toIntOrNull() }
.forEachIndexed { index, typeId ->
val exId = db.sessionDao().insertExercise(
LocalExercise(sessionId = sessionId, exerciseTypeId = typeId, order = index + 1)
)
enqueue(OpKind.ADD_EXERCISE, exId)
}
return sessionId
}
suspend fun addExercise(sessionId: Long, exerciseTypeId: Int): Long {
val order = db.sessionDao().exerciseCount(sessionId) + 1
val id = db.sessionDao().insertExercise(
LocalExercise(sessionId = sessionId, exerciseTypeId = exerciseTypeId, order = order)
)
enqueue(OpKind.ADD_EXERCISE, id)
refreshLastPerformance(exerciseTypeId)
return id
}
suspend fun logSet(
exerciseId: Long,
reps: Int?,
weight: Double?,
distanceMeters: Double?,
durationSeconds: Int?,
rpe: Double?,
isWarmup: Boolean,
): Long {
val order = db.sessionDao().setCount(exerciseId) + 1
val id = db.sessionDao().insertSet(
LocalSet(
exerciseId = exerciseId,
order = order,
reps = reps,
weight = weight,
distanceMeters = distanceMeters,
durationSeconds = durationSeconds,
rpe = rpe,
isWarmup = isWarmup,
loggedAtEpochMs = System.currentTimeMillis(),
)
)
enqueue(OpKind.ADD_SET, id)
// Uppdatera cachens "senaste prestation" för förifyllnad nästa gång.
db.sessionDao().exercise(exerciseId)?.let { ex ->
db.cacheDao().updateLastPerformance(ex.exerciseTypeId, weight, reps, durationSeconds, distanceMeters)
}
return id
}
suspend fun updateSet(set: LocalSet) {
db.sessionDao().updateSet(set)
// Inte synkad än? Då bär den väntande ADD_SET-opsen de nya värdena
// automatiskt (den läser raden när den körs). Annars: egen update-op.
if (set.serverId != null) enqueue(OpKind.UPDATE_SET, set.id)
}
suspend fun deleteSet(setId: Long) {
val set = db.sessionDao().set(setId) ?: return
db.sessionDao().deleteSet(setId)
// Osynkat set: den väntande ADD-opsen blir en no-op när raden är borta.
// Synkat set: ta bort på servern också (server-id:t i op:en, raden finns inte kvar).
set.serverId?.let { serverId ->
db.opDao().enqueue(
PendingOp(
kind = OpKind.REMOVE_SET,
targetLocalId = serverId.toLong(),
createdAtEpochMs = System.currentTimeMillis(),
)
)
sync.kick()
}
}
suspend fun completeSession(
sessionId: Long,
durationSecondsOverride: Int? = null,
editedStartEpochMs: Long? = null,
) {
val session = db.sessionDao().session(sessionId) ?: return
db.sessionDao().updateSession(
session.copy(
status = "completed",
durationSecondsOverride = durationSecondsOverride,
editedStartEpochMs = editedStartEpochMs,
startedAtEpochMs = editedStartEpochMs ?: session.startedAtEpochMs,
)
)
enqueue(OpKind.COMPLETE_SESSION, sessionId)
}
suspend fun bodyWeightKg(): Double? = store.bodyWeightKg()
suspend fun displayName(): String? = store.displayName()
/* ---------- Kroppsmätningar ---------- */
suspend fun fetchBodyMeasurements(): List<BodyMeasurement> {
val data = client.execute(
"query{bodyMeasurements{id date weightKg musclePercent fatPercent waterPercent}}",
token = auth.bearerToken(),
)
return data["bodyMeasurements"]!!.jsonArray.map { m ->
val o = m.jsonObject
BodyMeasurement(
id = o["id"]!!.jsonPrimitive.content,
date = o["date"]?.jsonPrimitive?.contentOrNull() ?: "",
weightKg = o["weightKg"]?.jsonPrimitive?.doubleOrNull ?: 0.0,
musclePercent = o["musclePercent"]?.jsonPrimitive?.doubleOrNull,
fatPercent = o["fatPercent"]?.jsonPrimitive?.doubleOrNull,
waterPercent = o["waterPercent"]?.jsonPrimitive?.doubleOrNull,
)
}
}
/** Ny mätning. Faller tillbaka på gamla updateBodyWeight mot äldre API. */
suspend fun addBodyMeasurement(
weightKg: Double,
dateIso: String? = null,
musclePercent: Double? = null,
fatPercent: Double? = null,
waterPercent: Double? = null,
) {
try {
client.execute(
"mutation(\$input: AddBodyMeasurementInput!){addBodyMeasurement(input:\$input){id}}",
buildJsonObject {
put("input", buildJsonObject {
put("weightKg", weightKg)
dateIso?.let { put("date", it) }
musclePercent?.let { put("musclePercent", it) }
fatPercent?.let { put("fatPercent", it) }
waterPercent?.let { put("waterPercent", it) }
})
},
auth.bearerToken(),
)
} catch (e: GraphQlException) {
client.execute(
"mutation(\$w: Float){updateBodyWeight(bodyWeightKg:\$w){bodyWeightKg}}",
buildJsonObject { put("w", weightKg) },
auth.bearerToken(),
)
}
store.saveBodyWeight(weightKg)
}
/** null = rör inte fältet, -1 = rensa (serverns konvention). */
suspend fun updateBodyMeasurement(
id: String,
weightKg: Double,
dateIso: String?,
musclePercent: Double? = null,
fatPercent: Double? = null,
waterPercent: Double? = null,
) {
client.execute(
"mutation(\$input: UpdateBodyMeasurementInput!){updateBodyMeasurement(input:\$input){id}}",
buildJsonObject {
put("input", buildJsonObject {
put("id", id)
put("weightKg", weightKg)
dateIso?.let { put("date", it) }
musclePercent?.let { put("musclePercent", it) }
fatPercent?.let { put("fatPercent", it) }
waterPercent?.let { put("waterPercent", it) }
})
},
auth.bearerToken(),
)
}
suspend fun deleteBodyMeasurement(id: String) {
client.execute(
"mutation(\$id: UUID!){deleteBodyMeasurement(id:\$id)}",
buildJsonObject { put("id", id) },
auth.bearerToken(),
)
}
/** Cachade personbästa som (exerciseTypeId, reps) → bästa vikt. */
suspend fun personalBests(): Map<Pair<Int, Int>, Double> =
db.cacheDao().personalBests().associate { (it.exerciseTypeId to it.reps) to it.weight }
/* ---------- Referensdata / cache ---------- */
/** Hämta om all referensdata. Tyst vid nätfel — cachen gäller tills vidare. */
fun refreshAllAsync() {
scope.launch {
runCatching { refreshReferenceData() }
runCatching { adoptServerActiveSession() }
sync.kick()
}
}
private suspend fun refreshReferenceData() {
val token = auth.bearerToken()
// Varje sektion för sig, och de nya API-fälten (iconKey,
// defaultRestSeconds) med fallback — en äldre server ska aldrig
// kunna lämna hela cachen tom.
runCatching {
val groups = try {
client.execute("query{muscleGroups{id name iconKey}}", token = token)
} catch (e: GraphQlException) {
client.execute("query{muscleGroups{id name}}", token = token)
}
db.cacheDao().upsertMuscleGroups(
groups["muscleGroups"]!!.jsonArray.map {
val o = it.jsonObject
CachedMuscleGroup(
o["id"]!!.jsonPrimitive.int,
o["name"]!!.jsonPrimitive.content,
o["iconKey"]?.jsonPrimitive?.contentOrNull(),
)
}
)
}
runCatching {
val muscles = client.execute("query{muscles{id name muscleGroupId}}", token = token)
db.cacheDao().upsertMuscles(
muscles["muscles"]!!.jsonArray.map {
val o = it.jsonObject
CachedMuscle(
o["id"]!!.jsonPrimitive.int,
o["name"]!!.jsonPrimitive.content,
o["muscleGroupId"]?.jsonPrimitive?.intOrNull,
)
}
)
}
runCatching {
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 {
val prof = client.execute("query{myProfile{bodyWeightKg displayName}}", token = token)
val p = prof["myProfile"]?.jsonObject
store.saveBodyWeight(p?.get("bodyWeightKg")?.jsonPrimitive?.doubleOrNull)
store.saveDisplayName(p?.get("displayName")?.jsonPrimitive?.contentOrNull())
}
val favIds: Set<Int> = runCatching {
client.execute("query{favoriteExercises{id}}", token = token)["favoriteExercises"]!!
.jsonArray.map { it.jsonObject["id"]!!.jsonPrimitive.int }.toSet()
}.getOrDefault(emptySet())
val typeFields =
"id name description metValue tracksWeight tracksReps tracksDistance tracksDuration isBodyweight"
val types = try {
client.execute(
"query{exerciseTypes{$typeFields defaultRestSeconds exerciseTypeMuscles{muscle{id}}}}",
token = token,
)
} catch (e: GraphQlException) {
client.execute(
"query{exerciseTypes{$typeFields exerciseTypeMuscles{muscle{id}}}}",
token = token,
)
}
db.cacheDao().replaceExerciseTypes(
types["exerciseTypes"]!!.jsonArray.map {
val o = it.jsonObject
CachedExerciseType(
id = o["id"]!!.jsonPrimitive.int,
name = o["name"]!!.jsonPrimitive.content,
description = o["description"]?.jsonPrimitive?.contentOrNull(),
metValue = o["metValue"]?.jsonPrimitive?.doubleOrNull ?: 5.0,
tracksWeight = o["tracksWeight"]?.jsonPrimitive?.booleanOrNull ?: false,
tracksReps = o["tracksReps"]?.jsonPrimitive?.booleanOrNull ?: false,
tracksDistance = o["tracksDistance"]?.jsonPrimitive?.booleanOrNull ?: false,
tracksDuration = o["tracksDuration"]?.jsonPrimitive?.booleanOrNull ?: false,
isBodyweight = o["isBodyweight"]?.jsonPrimitive?.booleanOrNull ?: false,
defaultRestSeconds = o["defaultRestSeconds"]?.jsonPrimitive?.intOrNull,
muscleIds = o["exerciseTypeMuscles"]?.jsonArray
?.mapNotNull { m -> m.jsonObject["muscle"]?.jsonObject?.get("id")?.jsonPrimitive?.intOrNull }
?.joinToString(",") ?: "",
isFavorite = o["id"]!!.jsonPrimitive.int in favIds,
)
}
)
// Startkort: mallar + favoritpass
val cards = mutableListOf<CachedStartCard>()
runCatching {
val templates = client.execute(
"query{workoutTemplates{id name exercises{order exerciseType{id}}}}",
token = token,
)
templates["workoutTemplates"]!!.jsonArray.forEachIndexed { i, t ->
val o = t.jsonObject
val ids = o["exercises"]!!.jsonArray
.sortedBy { e -> e.jsonObject["order"]?.jsonPrimitive?.intOrNull ?: 0 }
.mapNotNull { e -> e.jsonObject["exerciseType"]?.jsonObject?.get("id")?.jsonPrimitive?.intOrNull }
cards += CachedStartCard(
key = "template-${o["id"]!!.jsonPrimitive.int}",
title = o["name"]!!.jsonPrimitive.content,
subtitle = "Mall · ${ids.size} övningar",
exerciseTypeIds = ids.joinToString(","),
templateId = o["id"]!!.jsonPrimitive.int,
sortOrder = i,
)
}
}
runCatching {
val favs = client.execute(
"query{favoriteGymSessions{id name gymSessionExercises{order exerciseType{id}}}}",
token = token,
)
favs["favoriteGymSessions"]!!.jsonArray.forEachIndexed { i, s ->
val o = s.jsonObject
val ids = o["gymSessionExercises"]!!.jsonArray
.sortedBy { e -> e.jsonObject["order"]?.jsonPrimitive?.intOrNull ?: 0 }
.mapNotNull { e -> e.jsonObject["exerciseType"]?.jsonObject?.get("id")?.jsonPrimitive?.intOrNull }
cards += CachedStartCard(
key = "favsession-${o["id"]!!.jsonPrimitive.int}",
title = o["name"]?.jsonPrimitive?.contentOrNull() ?: "Favoritpass",
subtitle = "Favorit · ${ids.size} övningar",
exerciseTypeIds = ids.joinToString(","),
sortOrder = 100 + i,
)
}
}
db.cacheDao().clearStartCards()
db.cacheDao().upsertStartCards(cards)
}
/** Om servern har ett aktivt pass men appen saknar ett: ta över det lokalt. */
private suspend fun adoptServerActiveSession() {
if (db.sessionDao().activeSessionIdNow() != null) return
val token = auth.bearerToken()
val data = client.execute(
"query{activeGymSession{id name notes startTime gymSessionExercises{id order notes exerciseType{id} sets{id order reps weight distanceMeters durationSeconds rpe isWarmup isCompleted}}}}",
token = token,
)
val s = data["activeGymSession"] ?: return
if (s is kotlinx.serialization.json.JsonNull) return
val o = s.jsonObject
val sessionId = db.sessionDao().insertSession(
LocalSession(
serverId = o["id"]!!.jsonPrimitive.int,
name = o["name"]?.jsonPrimitive?.contentOrNull(),
notes = o["notes"]?.jsonPrimitive?.contentOrNull(),
startedAtEpochMs = System.currentTimeMillis(),
)
)
o["gymSessionExercises"]?.jsonArray?.forEach { e ->
val eo = e.jsonObject
val exId = db.sessionDao().insertExercise(
LocalExercise(
sessionId = sessionId,
serverId = eo["id"]!!.jsonPrimitive.int,
exerciseTypeId = eo["exerciseType"]!!.jsonObject["id"]!!.jsonPrimitive.int,
order = eo["order"]?.jsonPrimitive?.intOrNull ?: 0,
notes = eo["notes"]?.jsonPrimitive?.contentOrNull(),
)
)
eo["sets"]?.jsonArray?.forEach { st ->
val so = st.jsonObject
db.sessionDao().insertSet(
LocalSet(
exerciseId = exId,
serverId = so["id"]!!.jsonPrimitive.int,
order = so["order"]?.jsonPrimitive?.intOrNull ?: 0,
reps = so["reps"]?.jsonPrimitive?.intOrNull,
weight = so["weight"]?.jsonPrimitive?.doubleOrNull,
distanceMeters = so["distanceMeters"]?.jsonPrimitive?.doubleOrNull,
durationSeconds = so["durationSeconds"]?.jsonPrimitive?.intOrNull,
rpe = so["rpe"]?.jsonPrimitive?.doubleOrNull,
isWarmup = so["isWarmup"]?.jsonPrimitive?.booleanOrNull ?: false,
isCompleted = so["isCompleted"]?.jsonPrimitive?.booleanOrNull ?: true,
loggedAtEpochMs = System.currentTimeMillis(),
)
)
}
}
}
private fun refreshLastPerformance(exerciseTypeId: Int) {
scope.launch {
runCatching {
val data = client.execute(
"query(\$id:Int!){lastExercisePerformance(exerciseTypeId:\$id){sets{reps weight distanceMeters durationSeconds}}}",
buildJsonObject { put("id", exerciseTypeId) },
auth.bearerToken(),
)
val perf = data["lastExercisePerformance"] ?: return@launch
if (perf is kotlinx.serialization.json.JsonNull) return@launch
val best = perf.jsonObject["sets"]?.jsonArray?.lastOrNull()?.jsonObject ?: return@launch
db.cacheDao().updateLastPerformance(
exerciseTypeId,
best["weight"]?.jsonPrimitive?.doubleOrNull,
best["reps"]?.jsonPrimitive?.intOrNull,
best["durationSeconds"]?.jsonPrimitive?.intOrNull,
best["distanceMeters"]?.jsonPrimitive?.doubleOrNull,
)
}
}
}
fun toggleFavoriteExercise(exerciseTypeId: Int, favorite: Boolean) {
scope.launch {
db.cacheDao().setExerciseFavorite(exerciseTypeId, favorite)
runCatching {
val mutation = if (favorite) {
"mutation(\$id:Int!){addFavoriteExercise(exerciseTypeId:\$id){id}}"
} else {
"mutation(\$id:Int!){removeFavoriteExercise(exerciseTypeId:\$id)}"
}
client.execute(mutation, buildJsonObject { put("id", exerciseTypeId) }, auth.bearerToken())
}
}
}
/** Full detalj för ett avslutat pass. */
suspend fun fetchSessionDetail(id: Int): SessionDetail {
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}}}}",
buildJsonObject { put("id", id) },
auth.bearerToken(),
)
val o = data["gymSession"]!!.jsonObject
return SessionDetail(
id = o["id"]!!.jsonPrimitive.int,
name = o["name"]?.jsonPrimitive?.contentOrNull() ?: "Pass",
date = (o["date"]?.jsonPrimitive?.contentOrNull()
?: o["startTime"]?.jsonPrimitive?.contentOrNull()).orEmpty(),
notes = o["notes"]?.jsonPrimitive?.contentOrNull(),
calories = o["estimatedCalories"]?.jsonPrimitive?.doubleOrNull,
durationMinutes = durationMinutes(
o["durationSecondsOverride"]?.jsonPrimitive?.intOrNull,
o["startTime"]?.jsonPrimitive?.contentOrNull(),
o["endTime"]?.jsonPrimitive?.contentOrNull(),
),
exercises = o["gymSessionExercises"]!!.jsonArray
.sortedBy { it.jsonObject["order"]?.jsonPrimitive?.intOrNull ?: 0 }
.map { e ->
val eo = e.jsonObject
SessionDetailExercise(
name = eo["exerciseType"]?.jsonObject?.get("name")?.jsonPrimitive?.contentOrNull()
?: "Övning",
sets = eo["sets"]!!.jsonArray.map(::parseHistorySet)
.sortedBy { it.order },
)
},
)
}
/**
* Historik för en övning: tidigare pass med dess set, plus tyngsta
* lyften de senaste tre månaderna. Filtreras klient-side ur de
* senaste avslutade passen.
*/
suspend fun fetchExerciseHistory(exerciseTypeId: Int, limit: Int = 40): ExerciseHistory {
val data = client.execute(
"query(\$s:String,\$l:Int){gymSessions(status:\$s,limit:\$l){name date startTime gymSessionExercises{exerciseType{id} sets{order reps weight distanceMeters durationSeconds rpe isWarmup}}}}",
buildJsonObject { put("s", "completed"); put("l", limit) },
auth.bearerToken(),
)
val entries = data["gymSessions"]!!.jsonArray.mapNotNull { s ->
val o = s.jsonObject
val sets = o["gymSessionExercises"]!!.jsonArray
.filter {
it.jsonObject["exerciseType"]?.jsonObject?.get("id")?.jsonPrimitive?.intOrNull == exerciseTypeId
}
.flatMap { it.jsonObject["sets"]!!.jsonArray }
.map(::parseHistorySet)
.sortedBy { it.order }
if (sets.isEmpty()) return@mapNotNull null
ExerciseHistoryEntry(
date = (o["date"]?.jsonPrimitive?.contentOrNull()
?: o["startTime"]?.jsonPrimitive?.contentOrNull()).orEmpty().take(10),
sessionName = o["name"]?.jsonPrimitive?.contentOrNull() ?: "Pass",
sets = sets,
)
}.sortedByDescending { it.date }
val cutoff = java.time.LocalDate.now().minusMonths(3).toString()
val topLifts = entries
.filter { it.date >= cutoff }
.flatMap { entry ->
entry.sets.filter { !it.isWarmup && it.weight != null }
.map { TopLift(entry.date, it.weight!!, it.reps) }
}
.sortedWith(compareByDescending<TopLift> { it.weight }.thenByDescending { it.reps ?: 0 })
.take(10)
return ExerciseHistory(entries = entries, topLifts = topLifts)
}
private fun parseHistorySet(element: kotlinx.serialization.json.JsonElement): HistorySet {
val so = element.jsonObject
return HistorySet(
order = so["order"]?.jsonPrimitive?.intOrNull ?: 0,
weight = so["weight"]?.jsonPrimitive?.doubleOrNull,
reps = so["reps"]?.jsonPrimitive?.intOrNull,
distanceMeters = so["distanceMeters"]?.jsonPrimitive?.doubleOrNull,
durationSeconds = so["durationSeconds"]?.jsonPrimitive?.intOrNull,
rpe = so["rpe"]?.jsonPrimitive?.doubleOrNull,
isWarmup = so["isWarmup"]?.jsonPrimitive?.booleanOrNull ?: false,
)
}
/** Statistik för en period: KPI:er + muskelvolym + PB-lista. */
suspend fun fetchStats(period: String, referenceDate: String? = null): StatsBundle {
val token = auth.bearerToken()
val summaryData = client.execute(
"query(\$p:String!,\$d:String){gymSessionSummary(period:\$p,referenceDate:\$d){" +
"sessionCount totalDurationMinutes avgDurationMinutes totalCalories totalVolumeKg totalSets totalReps " +
"sessionsPerWeek currentStreakWeeks " +
"previous{sessionCount totalDurationMinutes totalCalories totalVolumeKg totalSets} " +
"trend{label totalVolumeKg sessionCount} " +
"highlights{heaviestLift{exerciseName weight reps date} newPrCount}}}",
buildJsonObject { put("p", period); referenceDate?.let { put("d", it) } },
token,
)
val s = summaryData["gymSessionSummary"]!!.jsonObject
val prev = s["previous"]?.takeIf { it !is kotlinx.serialization.json.JsonNull }?.jsonObject
val highlights = s["highlights"]?.takeIf { it !is kotlinx.serialization.json.JsonNull }?.jsonObject
val heaviest = highlights?.get("heaviestLift")
?.takeIf { it !is kotlinx.serialization.json.JsonNull }?.jsonObject
val muscleVolumes: Map<String, Double> = runCatching {
val ws = client.execute(
"query(\$p:String!,\$d:String){workoutStats(period:\$p,referenceDate:\$d){muscleGroupStats{muscleGroupName totalVolume}}}",
buildJsonObject { put("p", period); referenceDate?.let { put("d", it) } },
token,
)
ws["workoutStats"]!!.jsonObject["muscleGroupStats"]!!.jsonArray.associate {
val o = it.jsonObject
(o["muscleGroupName"]?.jsonPrimitive?.contentOrNull() ?: "?") to
(o["totalVolume"]?.jsonPrimitive?.doubleOrNull ?: 0.0)
}
}.getOrDefault(emptyMap())
val pbs: List<PbEntry> = runCatching {
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 prevKpi(key: String) = prev?.get(key)?.jsonPrimitive?.doubleOrNull
return StatsBundle(
sessionCount = kpi("sessionCount").toInt(),
prevSessionCount = prevKpi("sessionCount")?.toInt(),
totalDurationMinutes = kpi("totalDurationMinutes").toInt(),
prevDurationMinutes = prevKpi("totalDurationMinutes")?.toInt(),
totalCalories = kpi("totalCalories"),
prevCalories = prevKpi("totalCalories"),
totalVolumeKg = kpi("totalVolumeKg"),
prevVolumeKg = prevKpi("totalVolumeKg"),
totalSets = kpi("totalSets").toInt(),
totalReps = kpi("totalReps").toInt(),
sessionsPerWeek = kpi("sessionsPerWeek"),
streakWeeks = kpi("currentStreakWeeks").toInt(),
trend = s["trend"]?.jsonArray?.map {
val o = it.jsonObject
TrendBucket(
label = o["label"]?.jsonPrimitive?.contentOrNull() ?: "",
volumeKg = o["totalVolumeKg"]?.jsonPrimitive?.doubleOrNull ?: 0.0,
sessionCount = o["sessionCount"]?.jsonPrimitive?.intOrNull ?: 0,
)
}.orEmpty(),
heaviestLift = heaviest?.let {
"${it["exerciseName"]?.jsonPrimitive?.contentOrNull()}: " +
"${it["weight"]?.jsonPrimitive?.doubleOrNull ?: 0.0} kg × " +
"${it["reps"]?.jsonPrimitive?.intOrNull ?: 0}"
},
newPrCount = highlights?.get("newPrCount")?.jsonPrimitive?.intOrNull ?: 0,
muscleVolumes = muscleVolumes,
personalBests = pbs,
)
}
/** Serverns kaloriberäkning och tidsfördelning för ett pass. */
suspend fun fetchSessionStats(id: Int): SessionStats {
val data = client.execute(
"query(\$id:Int!){gymSessionStats(id:\$id){durationMinutes estimatedCalories totalSets totalReps totalVolumeKg exerciseBreakdown{exerciseTypeId exerciseName allocatedSeconds estimatedCalories totalSets totalReps maxWeight volumeKg}}}",
buildJsonObject { put("id", id) },
auth.bearerToken(),
)
val o = data["gymSessionStats"]!!.jsonObject
return SessionStats(
durationMinutes = o["durationMinutes"]?.jsonPrimitive?.intOrNull,
estimatedCalories = o["estimatedCalories"]?.jsonPrimitive?.doubleOrNull,
totalSets = o["totalSets"]?.jsonPrimitive?.intOrNull ?: 0,
totalReps = o["totalReps"]?.jsonPrimitive?.intOrNull ?: 0,
totalVolumeKg = o["totalVolumeKg"]?.jsonPrimitive?.doubleOrNull ?: 0.0,
breakdown = o["exerciseBreakdown"]?.jsonArray?.map { e ->
val eo = e.jsonObject
val typeId = eo["exerciseTypeId"]?.jsonPrimitive?.intOrNull
ExerciseStats(
exerciseTypeId = typeId,
name = eo["exerciseName"]?.jsonPrimitive?.contentOrNull() ?: "Övning",
allocatedSeconds = eo["allocatedSeconds"]?.jsonPrimitive?.intOrNull,
estimatedCalories = eo["estimatedCalories"]?.jsonPrimitive?.doubleOrNull,
metValue = typeId?.let { db.cacheDao().exerciseType(it)?.metValue },
)
}.orEmpty(),
)
}
/** Historik hämtas direkt (ingen offline-garanti i v1). */
suspend fun fetchHistory(limit: Int = 30): List<HistoryItem> {
val data = client.execute(
"query(\$s:String,\$l:Int){gymSessions(status:\$s,limit:\$l){id name date startTime endTime durationSecondsOverride estimatedCalories gymSessionExercises{id sets{id weight reps}}}}",
buildJsonObject { put("s", "completed"); put("l", limit) },
auth.bearerToken(),
)
return data["gymSessions"]!!.jsonArray.map { s ->
val o = s.jsonObject
val sets = o["gymSessionExercises"]!!.jsonArray.flatMap { it.jsonObject["sets"]!!.jsonArray }
val volume = sets.sumOf { st ->
val so = st.jsonObject
(so["weight"]?.jsonPrimitive?.doubleOrNull ?: 0.0) * (so["reps"]?.jsonPrimitive?.intOrNull ?: 0)
}
HistoryItem(
id = o["id"]!!.jsonPrimitive.int,
name = o["name"]?.jsonPrimitive?.contentOrNull() ?: "Pass",
date = o["date"]?.jsonPrimitive?.contentOrNull()
?: o["startTime"]?.jsonPrimitive?.contentOrNull() ?: "",
exerciseCount = o["gymSessionExercises"]!!.jsonArray.size,
setCount = sets.size,
volumeKg = volume,
calories = o["estimatedCalories"]?.jsonPrimitive?.doubleOrNull,
durationMinutes = durationMinutes(
o["durationSecondsOverride"]?.jsonPrimitive?.intOrNull,
o["startTime"]?.jsonPrimitive?.contentOrNull(),
o["endTime"]?.jsonPrimitive?.contentOrNull(),
),
)
}
}
/** Passlängd i minuter: manuell rättning vinner, annars slut start. */
private fun durationMinutes(overrideSeconds: Int?, start: String?, end: String?): Int? {
overrideSeconds?.let { return it / 60 }
if (start == null || end == null) return null
return runCatching {
val s = java.time.OffsetDateTime.parse(start)
val e = java.time.OffsetDateTime.parse(end)
java.time.Duration.between(s, e).toMinutes().toInt().takeIf { it >= 0 }
}.getOrNull()
}
}
data class HistoryItem(
val id: Int,
val name: String,
val date: String,
val exerciseCount: Int,
val setCount: Int,
val volumeKg: Double,
val calories: Double?,
val durationMinutes: Int? = null,
)
data class HistorySet(
val order: Int,
val weight: Double?,
val reps: Int?,
val distanceMeters: Double?,
val durationSeconds: Int?,
val rpe: Double?,
val isWarmup: Boolean,
)
data class SessionDetail(
val id: Int,
val name: String,
val date: String,
val notes: String?,
val calories: Double?,
val durationMinutes: Int? = null,
val exercises: List<SessionDetailExercise>,
)
data class SessionDetailExercise(
val name: String,
val sets: List<HistorySet>,
)
data class ExerciseHistoryEntry(
val date: String,
val sessionName: String,
val sets: List<HistorySet>,
)
data class TopLift(val date: String, val weight: Double, val reps: Int?)
data class ExerciseHistory(
val entries: List<ExerciseHistoryEntry>,
val topLifts: List<TopLift>,
)
data class SessionStats(
val durationMinutes: Int?,
val estimatedCalories: Double?,
val totalSets: Int,
val totalReps: Int,
val totalVolumeKg: Double,
val breakdown: List<ExerciseStats>,
)
data class ExerciseStats(
val exerciseTypeId: Int?,
val name: String,
val allocatedSeconds: Int?,
val estimatedCalories: Double?,
val metValue: Double?,
)
data class BodyMeasurement(
val id: String,
val date: String,
val weightKg: Double,
val musclePercent: Double?,
val fatPercent: Double?,
val waterPercent: Double?,
)
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 PbEntry(val exerciseName: String, val records: List<PbRecord>)
data class StatsBundle(
val sessionCount: Int,
val prevSessionCount: Int?,
val totalDurationMinutes: Int,
val prevDurationMinutes: Int?,
val totalCalories: Double,
val prevCalories: Double?,
val totalVolumeKg: Double,
val prevVolumeKg: Double?,
val totalSets: Int,
val totalReps: Int,
val sessionsPerWeek: Double,
val streakWeeks: Int,
val trend: List<TrendBucket>,
val heaviestLift: String?,
val newPrCount: Int,
/** muskelgruppnamn → volym kg (för kroppskartan) */
val muscleVolumes: Map<String, Double>,
val personalBests: List<PbEntry>,
)
private fun kotlinx.serialization.json.JsonPrimitive.contentOrNull(): String? =
if (this is kotlinx.serialization.json.JsonNull) null else content

View File

@@ -0,0 +1,102 @@
package eu.brassepc.fitnessdroid.data
import android.content.Context
import android.media.RingtoneManager
import android.os.VibrationEffect
import android.os.VibratorManager
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
data class RestState(
val totalSeconds: Int,
val endsAtEpochMs: Long,
val secondsLeft: Int,
val finished: Boolean = false,
)
/**
* Vilotimern. Räknar mot en absolut sluttid (överlever att appen pausas)
* och larmar enligt inställningarna när tiden är ute.
*/
class RestTimerController(
private val context: Context,
private val settingsStore: SettingsStore,
private val scope: CoroutineScope,
) {
private val _state = MutableStateFlow<RestState?>(null)
val state: StateFlow<RestState?> = _state
private var ticker: Job? = null
fun start(seconds: Int? = null) {
scope.launch {
val settings = settingsStore.settings.first()
val total = seconds ?: settings.defaultRestSeconds
_state.value = RestState(
totalSeconds = total,
endsAtEpochMs = System.currentTimeMillis() + total * 1000L,
secondsLeft = total,
)
startTicker()
}
}
fun adjust(deltaSeconds: Int) {
val current = _state.value ?: return
if (current.finished) return
_state.value = current.copy(
endsAtEpochMs = (current.endsAtEpochMs + deltaSeconds * 1000L)
.coerceAtLeast(System.currentTimeMillis()),
totalSeconds = (current.totalSeconds + deltaSeconds).coerceAtLeast(5),
)
}
fun dismiss() {
ticker?.cancel()
_state.value = null
}
private fun startTicker() {
ticker?.cancel()
ticker = scope.launch {
while (true) {
val current = _state.value ?: return@launch
val left = (((current.endsAtEpochMs - System.currentTimeMillis()) + 999) / 1000)
.coerceAtLeast(0).toInt()
if (left != current.secondsLeft) {
_state.value = current.copy(secondsLeft = left)
}
if (left <= 0) {
_state.value = _state.value?.copy(secondsLeft = 0, finished = true)
alert()
// Låt "klart"-läget synas en stund, försvinn sedan självmant.
delay(8_000)
if (_state.value?.finished == true) _state.value = null
return@launch
}
delay(250)
}
}
}
private suspend fun alert() {
when (settingsStore.settings.first().restAlert) {
RestAlert.SOUND -> runCatching {
val uri = RingtoneManager.getDefaultUri(RingtoneManager.TYPE_NOTIFICATION)
RingtoneManager.getRingtone(context, uri)?.play()
}
RestAlert.VIBRATE -> runCatching {
val vm = context.getSystemService(Context.VIBRATOR_MANAGER_SERVICE) as VibratorManager
vm.defaultVibrator.vibrate(
VibrationEffect.createWaveform(longArrayOf(0, 350, 150, 350, 150, 500), -1)
)
}
RestAlert.VISUAL, RestAlert.NONE -> Unit
}
}
}

View File

@@ -0,0 +1,95 @@
package eu.brassepc.fitnessdroid.data
import android.content.Context
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.intPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
private val Context.settingsDataStore by preferencesDataStore(name = "settings")
enum class RestTimerStyle { FULLSCREEN, BANNER }
enum class RestAlert { SOUND, VIBRATE, VISUAL, NONE }
data class AppSettings(
val restTimerStyle: RestTimerStyle = RestTimerStyle.FULLSCREEN,
val restAlert: RestAlert = RestAlert.VIBRATE,
val defaultRestSeconds: Int = 90,
/** Steg för viktstepparna i passläget (kg) */
val weightStep: Double = 2.5,
/** Spara inloggningen krypterat och logga in tyst igen vid behov */
val autoRelogin: Boolean = true,
/** Egna stångvikter i skivkalkylatorn (utöver snabbvalen 20/0 kg) */
val customBarWeights: List<Double> = listOf(15.0),
)
class SettingsStore(private val context: Context) {
val settings: Flow<AppSettings> = context.settingsDataStore.data.map { prefs ->
AppSettings(
restTimerStyle = prefs[KEY_TIMER_STYLE]?.let {
runCatching { RestTimerStyle.valueOf(it) }.getOrNull()
} ?: RestTimerStyle.FULLSCREEN,
restAlert = prefs[KEY_REST_ALERT]?.let {
runCatching { RestAlert.valueOf(it) }.getOrNull()
} ?: RestAlert.VIBRATE,
defaultRestSeconds = prefs[KEY_REST_SECONDS] ?: 90,
weightStep = prefs[KEY_WEIGHT_STEP]?.toDoubleOrNull() ?: 2.5,
autoRelogin = prefs[KEY_AUTO_RELOGIN]?.toBooleanStrictOrNull() ?: true,
customBarWeights = prefs[KEY_CUSTOM_BARS]?.split(",")
?.mapNotNull { it.toDoubleOrNull() }
// migrera från gamla enkel-värdet
?: prefs[KEY_CUSTOM_BAR]?.toDoubleOrNull()?.let { listOf(it) }
?: listOf(15.0),
)
}
suspend fun addCustomBarWeight(value: Double) {
val v = value.coerceIn(0.1, 100.0)
context.settingsDataStore.edit { prefs ->
val current = prefs[KEY_CUSTOM_BARS]?.split(",")
?.mapNotNull { it.toDoubleOrNull() } ?: listOf(15.0)
prefs[KEY_CUSTOM_BARS] = (current + v).distinct().sorted().joinToString(",")
}
}
suspend fun removeCustomBarWeight(value: Double) {
context.settingsDataStore.edit { prefs ->
val current = prefs[KEY_CUSTOM_BARS]?.split(",")
?.mapNotNull { it.toDoubleOrNull() } ?: listOf(15.0)
prefs[KEY_CUSTOM_BARS] = current.filter { it != value }.joinToString(",")
}
}
suspend fun setWeightStep(value: Double) {
context.settingsDataStore.edit { it[KEY_WEIGHT_STEP] = value.toString() }
}
suspend fun setAutoRelogin(value: Boolean) {
context.settingsDataStore.edit { it[KEY_AUTO_RELOGIN] = value.toString() }
}
suspend fun setRestTimerStyle(value: RestTimerStyle) {
context.settingsDataStore.edit { it[KEY_TIMER_STYLE] = value.name }
}
suspend fun setRestAlert(value: RestAlert) {
context.settingsDataStore.edit { it[KEY_REST_ALERT] = value.name }
}
suspend fun setDefaultRestSeconds(value: Int) {
context.settingsDataStore.edit { it[KEY_REST_SECONDS] = value.coerceIn(10, 600) }
}
companion object {
private val KEY_TIMER_STYLE = stringPreferencesKey("rest_timer_style")
private val KEY_REST_ALERT = stringPreferencesKey("rest_alert")
private val KEY_REST_SECONDS = intPreferencesKey("default_rest_seconds")
private val KEY_WEIGHT_STEP = stringPreferencesKey("weight_step")
private val KEY_AUTO_RELOGIN = stringPreferencesKey("auto_relogin")
private val KEY_CUSTOM_BAR = stringPreferencesKey("custom_bar_weight")
private val KEY_CUSTOM_BARS = stringPreferencesKey("custom_bar_weights")
}
}

View File

@@ -0,0 +1,213 @@
package eu.brassepc.fitnessdroid.data
import eu.brassepc.fitnessdroid.data.local.AppDatabase
import eu.brassepc.fitnessdroid.data.local.PendingOp
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.int
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import kotlinx.serialization.json.put
import java.io.IOException
object OpKind {
const val START_SESSION = "start_session"
const val ADD_EXERCISE = "add_exercise"
const val ADD_SET = "add_set"
const val UPDATE_SET = "update_set"
/** targetLocalId bär server-id:t — den lokala raden är redan borta */
const val REMOVE_SET = "remove_set"
const val COMPLETE_SESSION = "complete_session"
}
enum class SyncStatus { SYNCED, PENDING, OFFLINE, ERROR }
/**
* Kör op-kön mot servern i strikt ordning (FIFO). Varje op pekar på en lokal
* rad; server-id:n som kommer tillbaka skrivs in på raden så att efterföljande
* ops kan referera dem. Nätfel → backoff och nytt försök; kön ligger kvar i
* Room så inget tappas om appen dödas.
*/
class SyncEngine(
private val db: AppDatabase,
private val client: GraphQlClient,
private val auth: AuthRepository,
private val scope: CoroutineScope,
) {
private val mutex = Mutex()
private val _status = MutableStateFlow(SyncStatus.SYNCED)
val status: StateFlow<SyncStatus> = _status
val pendingCount = db.opDao().pendingCount()
/** Puffa igång kön; ofarlig att kalla ofta. */
fun kick() {
scope.launch { drain() }
}
suspend fun drain() {
mutex.withLock {
while (true) {
val op = db.opDao().next() ?: break
try {
execute(op)
db.opDao().done(op.id)
_status.value = if (db.opDao().pendingCountNow() == 0) {
SyncStatus.SYNCED
} else {
SyncStatus.PENDING
}
} catch (e: IOException) {
// Inget nät — försök igen senare, rör inte kön.
_status.value = SyncStatus.OFFLINE
scheduleRetry(op)
return
} catch (e: GraphQlException) {
// Servern sa nej. Behåll op:en för felsökning men fortsätt inte —
// efterföljande ops kan bero på den här.
db.opDao().update(
op.copy(attempts = op.attempts + 1, lastError = e.message)
)
_status.value = SyncStatus.ERROR
scheduleRetry(op, longDelay = true)
return
}
}
}
}
private var retryScheduled = false
private fun scheduleRetry(op: PendingOp, longDelay: Boolean = false) {
if (retryScheduled) return
retryScheduled = true
scope.launch {
val backoff = if (longDelay) 30_000L else 5_000L
delay(backoff * (op.attempts + 1).coerceAtMost(6))
retryScheduled = false
drain()
}
}
private suspend fun execute(op: PendingOp) {
val dao = db.sessionDao()
when (op.kind) {
OpKind.START_SESSION -> {
val session = dao.session(op.targetLocalId) ?: return
if (session.serverId != null) return
val input = buildJsonObject {
session.name?.let { put("name", it) }
session.notes?.let { put("notes", it) }
}
val data = client.execute(
"mutation(\$input: StartGymSessionInput!){startGymSession(input:\$input){id}}",
buildJsonObject { put("input", input) },
auth.bearerToken(),
)
val id = data["startGymSession"]!!.jsonObject["id"]!!.jsonPrimitive.int
dao.updateSession(session.copy(serverId = id))
}
OpKind.ADD_EXERCISE -> {
val exercise = dao.exercise(op.targetLocalId) ?: return
if (exercise.serverId != null) return
val session = dao.session(exercise.sessionId) ?: return
val sessionServerId = session.serverId
?: throw GraphQlException(listOf("Passet är inte synkat än"))
val input = buildJsonObject {
put("gymSessionId", sessionServerId)
put("exerciseTypeId", exercise.exerciseTypeId)
put("order", exercise.order)
exercise.notes?.let { put("notes", it) }
}
val data = client.execute(
"mutation(\$input: AddSessionExerciseInput!){addExerciseToSession(input:\$input){id}}",
buildJsonObject { put("input", input) },
auth.bearerToken(),
)
val id = data["addExerciseToSession"]!!.jsonObject["id"]!!.jsonPrimitive.int
dao.updateExercise(exercise.copy(serverId = id))
}
OpKind.ADD_SET, OpKind.UPDATE_SET -> {
val set = dao.set(op.targetLocalId) ?: return
if (op.kind == OpKind.ADD_SET && set.serverId != null) return
val exercise = dao.exercise(set.exerciseId) ?: return
val exerciseServerId = exercise.serverId
?: throw GraphQlException(listOf("Övningen är inte synkad än"))
val fields = buildJsonObject {
if (op.kind == OpKind.ADD_SET) {
put("sessionExerciseId", exerciseServerId)
} else {
put("id", set.serverId ?: throw GraphQlException(listOf("Setet är inte synkat än")))
}
set.reps?.let { put("reps", it) }
set.weight?.let { put("weight", it) }
set.distanceMeters?.let { put("distanceMeters", it) }
set.durationSeconds?.let { put("durationSeconds", it) }
set.rpe?.let { put("rpe", it) }
put("isWarmup", set.isWarmup)
put("isCompleted", set.isCompleted)
put("order", set.order)
}
if (op.kind == OpKind.ADD_SET) {
val data = client.execute(
"mutation(\$input: AddSessionSetInput!){addSessionSet(input:\$input){id}}",
buildJsonObject { put("input", fields) },
auth.bearerToken(),
)
val id = data["addSessionSet"]!!.jsonObject["id"]!!.jsonPrimitive.int
dao.updateSet(set.copy(serverId = id))
} else {
client.execute(
"mutation(\$input: UpdateSessionSetInput!){updateSessionSet(input:\$input){id}}",
buildJsonObject { put("input", fields) },
auth.bearerToken(),
)
}
}
OpKind.REMOVE_SET -> {
client.execute(
"mutation(\$id: Int!){removeSessionSet(id:\$id)}",
buildJsonObject { put("id", op.targetLocalId.toInt()) },
auth.bearerToken(),
)
}
OpKind.COMPLETE_SESSION -> {
val session = dao.session(op.targetLocalId) ?: return
val sessionServerId = session.serverId
?: throw GraphQlException(listOf("Passet är inte synkat än"))
// Rättad starttid: flytta passets datum innan det avslutas.
session.editedStartEpochMs?.let { startMs ->
val iso = java.time.Instant.ofEpochMilli(startMs).toString()
client.execute(
"mutation(\$input: UpdateGymSessionInput!){updateGymSession(input:\$input){id date}}",
buildJsonObject {
put("input", buildJsonObject {
put("id", sessionServerId)
put("date", iso)
})
},
auth.bearerToken(),
)
}
val input = buildJsonObject {
put("id", sessionServerId)
session.name?.let { put("name", it) }
session.durationSecondsOverride?.let { put("durationSecondsOverride", it) }
}
client.execute(
"mutation(\$input: CompleteGymSessionInput!){completeGymSession(input:\$input){id status}}",
buildJsonObject { put("input", input) },
auth.bearerToken(),
)
}
}
}
}

View File

@@ -1,6 +1,7 @@
package eu.brassepc.fitnessdroid.data package eu.brassepc.fitnessdroid.data
import android.content.Context import android.content.Context
import androidx.datastore.preferences.core.doublePreferencesKey
import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.longPreferencesKey import androidx.datastore.preferences.core.longPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey import androidx.datastore.preferences.core.stringPreferencesKey
@@ -63,6 +64,25 @@ class TokenStore(private val context: Context) {
} }
} }
/** Kroppsvikten cachas från profilen — behövs för kaloriuppskattningen offline. */
suspend fun saveBodyWeight(kg: Double?) {
context.authDataStore.edit {
if (kg == null) it.remove(KEY_BODY_WEIGHT) else it[KEY_BODY_WEIGHT] = kg
}
}
suspend fun bodyWeightKg(): Double? =
context.authDataStore.data.first()[KEY_BODY_WEIGHT]
suspend fun saveDisplayName(name: String?) {
context.authDataStore.edit {
if (name.isNullOrBlank()) it.remove(KEY_DISPLAY_NAME) else it[KEY_DISPLAY_NAME] = name
}
}
suspend fun displayName(): String? =
context.authDataStore.data.first()[KEY_DISPLAY_NAME]
/** Rensar sessionen men behåller vald API-url. */ /** Rensar sessionen men behåller vald API-url. */
suspend fun clearSession() { suspend fun clearSession() {
context.authDataStore.edit { context.authDataStore.edit {
@@ -83,5 +103,7 @@ class TokenStore(private val context: Context) {
private val KEY_REFRESH_TOKEN = stringPreferencesKey("refresh_token") private val KEY_REFRESH_TOKEN = stringPreferencesKey("refresh_token")
private val KEY_EXPIRATION = longPreferencesKey("expiration_epoch_ms") private val KEY_EXPIRATION = longPreferencesKey("expiration_epoch_ms")
private val KEY_API_URL = stringPreferencesKey("api_url") private val KEY_API_URL = stringPreferencesKey("api_url")
private val KEY_BODY_WEIGHT = doublePreferencesKey("body_weight_kg")
private val KEY_DISPLAY_NAME = stringPreferencesKey("display_name")
} }
} }

View File

@@ -0,0 +1,109 @@
package eu.brassepc.fitnessdroid.data
import android.content.Context
import android.content.Intent
import androidx.core.content.FileProvider
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.intOrNull
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import okhttp3.OkHttpClient
import okhttp3.Request
import java.io.File
import java.security.MessageDigest
import java.util.concurrent.TimeUnit
data class UpdateInfo(
val versionCode: Long,
val versionName: String,
val apkUrl: String,
val sha256: String?,
)
/**
* Uppdateringskoll mot släppsidans version.json (skrivs av CI:t).
* Nedladdning till appens cache + verifiering, sedan Androids
* installationsdialog — samma signeringsnyckel gör att den uppdaterar
* rakt över befintlig installation.
*/
class UpdateChecker(private val context: Context) {
private val http = OkHttpClient.Builder()
.connectTimeout(10, TimeUnit.SECONDS)
.readTimeout(120, TimeUnit.SECONDS)
.build()
private fun installedVersionCode(): Long =
context.packageManager.getPackageInfo(context.packageName, 0).longVersionCode
/** null = uppdaterad, nätfel eller trasig manifest — stör aldrig användaren. */
suspend fun check(): UpdateInfo? = withContext(Dispatchers.IO) {
runCatching {
val request = Request.Builder().url(VERSION_URL).build()
http.newCall(request).execute().use { response ->
if (!response.isSuccessful) return@withContext null
val o = Json.parseToJsonElement(response.body!!.string()).jsonObject
val info = UpdateInfo(
versionCode = (o["versionCode"]?.jsonPrimitive?.intOrNull ?: 0).toLong(),
versionName = o["versionName"]?.jsonPrimitive?.content ?: "?",
apkUrl = o["apkUrl"]?.jsonPrimitive?.content ?: return@withContext null,
sha256 = o["sha256"]?.jsonPrimitive?.content,
)
if (info.versionCode > installedVersionCode()) info else null
}
}.getOrNull()
}
/** Laddar ner APK:n till cachen, verifierar sha256, returnerar filen. */
suspend fun download(info: UpdateInfo, onProgress: (Float) -> Unit): File =
withContext(Dispatchers.IO) {
val dir = File(context.cacheDir, "updates").apply { mkdirs() }
val file = File(dir, "FitnessDroid-${info.versionCode}.apk")
val request = Request.Builder().url(info.apkUrl).build()
http.newCall(request).execute().use { response ->
if (!response.isSuccessful) error("HTTP ${response.code}")
val body = response.body!!
val total = body.contentLength().takeIf { it > 0 }
val digest = MessageDigest.getInstance("SHA-256")
body.byteStream().use { input ->
file.outputStream().use { output ->
val buffer = ByteArray(64 * 1024)
var read: Int
var done = 0L
while (input.read(buffer).also { read = it } != -1) {
output.write(buffer, 0, read)
digest.update(buffer, 0, read)
done += read
total?.let { onProgress(done.toFloat() / it) }
}
}
}
val actual = digest.digest().joinToString("") { "%02x".format(it) }
if (info.sha256 != null && !actual.equals(info.sha256, ignoreCase = true)) {
file.delete()
error("sha256 stämmer inte — avbryter")
}
}
file
}
/** Öppnar Androids installationsdialog för den nedladdade APK:n. */
fun install(file: File) {
val uri = FileProvider.getUriForFile(
context, "${context.packageName}.fileprovider", file,
)
context.startActivity(
Intent(Intent.ACTION_VIEW).apply {
setDataAndType(uri, "application/vnd.android.package-archive")
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK)
}
)
}
companion object {
private const val VERSION_URL =
"https://gitea.brasse-pc.eu/brasse/FitnessDroid/releases/download/latest/version.json"
}
}

View File

@@ -0,0 +1,60 @@
package eu.brassepc.fitnessdroid.data.local
import android.content.Context
import androidx.room.Database
import androidx.room.Room
import androidx.room.RoomDatabase
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
@Database(
entities = [
LocalSession::class,
LocalExercise::class,
LocalSet::class,
PendingOp::class,
CachedExerciseType::class,
CachedMuscleGroup::class,
CachedMuscle::class,
CachedStartCard::class,
CachedPb::class,
],
version = 4,
exportSchema = false,
)
abstract class AppDatabase : RoomDatabase() {
abstract fun sessionDao(): SessionDao
abstract fun opDao(): OpDao
abstract fun cacheDao(): CacheDao
companion object {
private val MIGRATION_1_2 = object : Migration(1, 2) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE local_session ADD COLUMN durationSecondsOverride INTEGER")
}
}
private val MIGRATION_2_3 = object : Migration(2, 3) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE local_session ADD COLUMN editedStartEpochMs INTEGER")
}
}
private val MIGRATION_3_4 = object : Migration(3, 4) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL(
"CREATE TABLE IF NOT EXISTS `cached_pb` (" +
"`key` TEXT NOT NULL, `exerciseTypeId` INTEGER NOT NULL, " +
"`reps` INTEGER NOT NULL, `weight` REAL NOT NULL, " +
"PRIMARY KEY(`key`))"
)
}
}
fun build(context: Context): AppDatabase =
Room.databaseBuilder(context, AppDatabase::class.java, "fitnessdroid.db")
.addMigrations(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4)
.fallbackToDestructiveMigration()
.build()
}
}

View File

@@ -0,0 +1,147 @@
package eu.brassepc.fitnessdroid.data.local
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.Query
import androidx.room.Transaction
import androidx.room.Update
import kotlinx.coroutines.flow.Flow
@Dao
interface SessionDao {
@Insert
suspend fun insertSession(session: LocalSession): Long
@Insert
suspend fun insertExercise(exercise: LocalExercise): Long
@Insert
suspend fun insertSet(set: LocalSet): Long
@Update
suspend fun updateSession(session: LocalSession)
@Update
suspend fun updateExercise(exercise: LocalExercise)
@Update
suspend fun updateSet(set: LocalSet)
@Query("SELECT * FROM local_session WHERE status = 'active' ORDER BY id DESC LIMIT 1")
fun activeSession(): Flow<LocalSession?>
@Query("SELECT * FROM local_session WHERE id = :id")
suspend fun session(id: Long): LocalSession?
@Query("SELECT id FROM local_session WHERE status = 'active' ORDER BY id DESC LIMIT 1")
suspend fun activeSessionIdNow(): Long?
@Query("SELECT * FROM local_exercise WHERE sessionId = :sessionId ORDER BY `order`")
fun exercises(sessionId: Long): Flow<List<LocalExercise>>
@Query("SELECT * FROM local_exercise WHERE id = :id")
suspend fun exercise(id: Long): LocalExercise?
@Query("SELECT * FROM local_set WHERE exerciseId IN (SELECT id FROM local_exercise WHERE sessionId = :sessionId) ORDER BY `order`")
fun setsForSession(sessionId: Long): Flow<List<LocalSet>>
@Query("SELECT * FROM local_set WHERE id = :id")
suspend fun set(id: Long): LocalSet?
@Query("SELECT COUNT(*) FROM local_exercise WHERE sessionId = :sessionId")
suspend fun exerciseCount(sessionId: Long): Int
@Query("SELECT COUNT(*) FROM local_set WHERE exerciseId = :exerciseId")
suspend fun setCount(exerciseId: Long): Int
@Query("DELETE FROM local_exercise WHERE id = :id")
suspend fun deleteExercise(id: Long)
@Query("DELETE FROM local_set WHERE id = :id")
suspend fun deleteSet(id: Long)
}
@Dao
interface OpDao {
@Insert
suspend fun enqueue(op: PendingOp): Long
@Update
suspend fun update(op: PendingOp)
@Query("SELECT * FROM pending_op ORDER BY id LIMIT 1")
suspend fun next(): PendingOp?
@Query("DELETE FROM pending_op WHERE id = :id")
suspend fun done(id: Long)
@Query("SELECT COUNT(*) FROM pending_op")
fun pendingCount(): Flow<Int>
@Query("SELECT COUNT(*) FROM pending_op")
suspend fun pendingCountNow(): Int
}
@Dao
interface CacheDao {
@Query("SELECT * FROM cached_exercise_type ORDER BY isFavorite DESC, name")
fun exerciseTypes(): Flow<List<CachedExerciseType>>
@Query("SELECT * FROM cached_exercise_type WHERE id = :id")
suspend fun exerciseType(id: Int): CachedExerciseType?
@Query("SELECT * FROM cached_muscle_group ORDER BY name")
fun muscleGroups(): Flow<List<CachedMuscleGroup>>
@Query("SELECT * FROM cached_muscle ORDER BY name")
fun muscles(): Flow<List<CachedMuscle>>
@Query("SELECT * FROM cached_start_card ORDER BY sortOrder")
fun startCards(): Flow<List<CachedStartCard>>
@Transaction
suspend fun replaceExerciseTypes(rows: List<CachedExerciseType>) {
// Behåll lastWeight/lastReps-fälten vid refresh — de fylls på separat.
for (row in rows) {
val old = exerciseType(row.id)
upsertExerciseType(
if (old != null) row.copy(
lastWeight = old.lastWeight,
lastReps = old.lastReps,
lastDurationSeconds = old.lastDurationSeconds,
lastDistanceMeters = old.lastDistanceMeters,
) else row
)
}
}
@androidx.room.Upsert
suspend fun upsertExerciseType(row: CachedExerciseType)
@androidx.room.Upsert
suspend fun upsertMuscleGroups(rows: List<CachedMuscleGroup>)
@androidx.room.Upsert
suspend fun upsertMuscles(rows: List<CachedMuscle>)
@Query("DELETE FROM cached_start_card")
suspend fun clearStartCards()
@androidx.room.Upsert
suspend fun upsertStartCards(rows: List<CachedStartCard>)
@Query("UPDATE cached_exercise_type SET lastWeight = :weight, lastReps = :reps, lastDurationSeconds = :duration, lastDistanceMeters = :distance WHERE id = :id")
suspend fun updateLastPerformance(id: Int, weight: Double?, reps: Int?, duration: Int?, distance: Double?)
@Query("UPDATE cached_exercise_type SET isFavorite = :favorite WHERE id = :id")
suspend fun setExerciseFavorite(id: Int, favorite: Boolean)
@Query("SELECT * FROM cached_pb")
suspend fun personalBests(): List<CachedPb>
@Query("DELETE FROM cached_pb")
suspend fun clearPersonalBests()
@androidx.room.Upsert
suspend fun upsertPersonalBests(rows: List<CachedPb>)
}

View File

@@ -0,0 +1,134 @@
package eu.brassepc.fitnessdroid.data.local
import androidx.room.Entity
import androidx.room.Index
import androidx.room.PrimaryKey
/**
* Lokal-först-modell: passet lever i Room medan du tränar och synkas till
* servern via op-kön ([PendingOp]) i bakgrunden. serverId är null tills
* motsvarande mutation gått igenom — det driver synk-indikatorerna i UI:t.
*/
@Entity(tableName = "local_session")
data class LocalSession(
@PrimaryKey(autoGenerate = true) val id: Long = 0,
val serverId: Int? = null,
val name: String? = null,
val notes: String? = null,
val startedAtEpochMs: Long,
/** "active" eller "completed" (lokalt avslutad, ev. ej synkad än) */
val status: String = "active",
/** Manuellt rättad passtid — skickas som durationSecondsOverride vid avslut */
val durationSecondsOverride: Int? = null,
/** Manuellt rättad starttid — skickas som updateGymSession(date) vid avslut */
val editedStartEpochMs: Long? = null,
)
@Entity(
tableName = "local_exercise",
indices = [Index("sessionId")],
)
data class LocalExercise(
@PrimaryKey(autoGenerate = true) val id: Long = 0,
val sessionId: Long,
val serverId: Int? = null,
val exerciseTypeId: Int,
val order: Int,
val notes: String? = null,
)
@Entity(
tableName = "local_set",
indices = [Index("exerciseId")],
)
data class LocalSet(
@PrimaryKey(autoGenerate = true) val id: Long = 0,
val exerciseId: Long,
val serverId: Int? = null,
val order: Int,
val reps: Int? = null,
val weight: Double? = null,
val distanceMeters: Double? = null,
val durationSeconds: Int? = null,
val rpe: Double? = null,
val isWarmup: Boolean = false,
val isCompleted: Boolean = true,
val loggedAtEpochMs: Long,
)
/** Kön av mutationer som väntar på att nå servern. Körs strikt i id-ordning. */
@Entity(tableName = "pending_op")
data class PendingOp(
@PrimaryKey(autoGenerate = true) val id: Long = 0,
/** se [eu.brassepc.fitnessdroid.data.OpKind] */
val kind: String,
/** Lokalt id på raden operationen gäller (session/övning/set beroende på kind) */
val targetLocalId: Long,
val createdAtEpochMs: Long,
val attempts: Int = 0,
val lastError: String? = null,
)
/* ---------- Referensdata-cache (funkar offline) ---------- */
@Entity(tableName = "cached_exercise_type")
data class CachedExerciseType(
@PrimaryKey val id: Int,
val name: String,
val description: String? = null,
val metValue: Double = 5.0,
val tracksWeight: Boolean = false,
val tracksReps: Boolean = false,
val tracksDistance: Boolean = false,
val tracksDuration: Boolean = false,
val isBodyweight: Boolean = false,
/** kommaseparerade muskel-id:n */
val muscleIds: String = "",
val isFavorite: Boolean = false,
/** från API-tillägget ExerciseType.defaultRestSeconds; null → appens standardvila */
val defaultRestSeconds: Int? = null,
/** Senast kända prestation (för förifyllnad offline) */
val lastWeight: Double? = null,
val lastReps: Int? = null,
val lastDurationSeconds: Int? = null,
val lastDistanceMeters: Double? = null,
)
@Entity(tableName = "cached_muscle_group")
data class CachedMuscleGroup(
@PrimaryKey val id: Int,
val name: String,
/** från API-tillägget MuscleGroup.iconKey; null → namnheuristik */
val iconKey: String? = null,
)
@Entity(tableName = "cached_muscle")
data class CachedMuscle(
@PrimaryKey val id: Int,
val name: String,
val muscleGroupId: Int? = null,
)
/** Personbästa per övning och rep-antal — för PB-markering vid avslut. */
@Entity(tableName = "cached_pb")
data class CachedPb(
/** "<exerciseTypeId>-<reps>" */
@PrimaryKey val key: String,
val exerciseTypeId: Int,
val reps: Int,
val weight: Double,
)
/** Favoritpass/mallar som visas på hemskärmen, cachade för offline-start. */
@Entity(tableName = "cached_start_card")
data class CachedStartCard(
/** "template-<id>" eller "favsession-<id>" */
@PrimaryKey val key: String,
val title: String,
val subtitle: String,
/** kommaseparerade exerciseTypeId:n i ordning — används för att starta passet */
val exerciseTypeIds: String,
val templateId: Int? = null,
val sortOrder: Int = 0,
)

View File

@@ -0,0 +1,225 @@
package eu.brassepc.fitnessdroid.ui
import androidx.compose.foundation.clickable
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.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.List
import androidx.compose.material.icons.filled.BarChart
import androidx.compose.material.icons.filled.Home
import androidx.compose.material.icons.filled.Person
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.NavigationBar
import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavHostController
import androidx.navigation.NavType
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController
import androidx.navigation.navArgument
import eu.brassepc.fitnessdroid.ui.history.HistoryDetailScreen
import eu.brassepc.fitnessdroid.ui.history.HistoryScreen
import eu.brassepc.fitnessdroid.ui.home.HomeScreen
import eu.brassepc.fitnessdroid.ui.picker.ExercisePickerScreen
import eu.brassepc.fitnessdroid.ui.profile.ProfileScreen
import eu.brassepc.fitnessdroid.ui.session.SessionScreen
import eu.brassepc.fitnessdroid.ui.settings.SettingsScreen
import eu.brassepc.fitnessdroid.ui.stats.StatsScreen
object Routes {
const val HOME = "home"
const val HISTORY = "history"
const val HISTORY_DETAIL = "history/{id}"
const val STATS = "stats"
const val PROFILE = "profile"
const val SETTINGS = "settings"
const val SESSION = "session"
const val PICKER = "picker"
fun historyDetail(id: Int) = "history/$id"
}
private data class TabItem(val route: String, val label: String, val icon: ImageVector)
private val tabs = listOf(
TabItem(Routes.HOME, "Hem", Icons.Default.Home),
TabItem(Routes.HISTORY, "Pass", Icons.AutoMirrored.Filled.List),
TabItem(Routes.STATS, "Statistik", Icons.Default.BarChart),
TabItem(Routes.PROFILE, "Profil", Icons.Default.Person),
)
@Composable
fun AppRoot(rootViewModel: RootViewModel = viewModel(factory = RootViewModel.Factory)) {
val nav = rememberNavController()
val backStack by nav.currentBackStackEntryAsState()
val route = backStack?.destination?.route
val hasActiveSession by rootViewModel.hasActiveSession.collectAsStateWithLifecycle(false)
val restState by rootViewModel.restState.collectAsStateWithLifecycle()
val pendingCount by rootViewModel.pendingCount.collectAsStateWithLifecycle(0)
val showChrome = route in tabs.map { it.route }
Scaffold(
bottomBar = {
if (showChrome) {
Column {
if (hasActiveSession) {
MiniPlayer(
restSecondsLeft = restState?.secondsLeft,
pendingCount = pendingCount,
onOpen = { nav.navigate(Routes.SESSION) },
)
}
BottomBar(nav = nav, currentRoute = route, onStart = {
rootViewModel.startOrResume { nav.navigate(Routes.SESSION) }
})
}
}
},
) { padding ->
NavHost(
navController = nav,
startDestination = Routes.HOME,
modifier = Modifier.padding(padding),
) {
composable(Routes.HOME) {
HomeScreen(
onOpenSession = { nav.navigate(Routes.SESSION) },
onOpenHistory = { nav.navigate(Routes.HISTORY) },
)
}
composable(Routes.HISTORY) {
HistoryScreen(onOpenSession = { id -> nav.navigate(Routes.historyDetail(id)) })
}
composable(
Routes.HISTORY_DETAIL,
arguments = listOf(navArgument("id") { type = NavType.IntType }),
) { entry ->
HistoryDetailScreen(
sessionId = entry.arguments?.getInt("id") ?: 0,
onBack = { nav.popBackStack() },
)
}
composable(Routes.STATS) { StatsScreen() }
composable(Routes.PROFILE) {
ProfileScreen(onOpenSettings = { nav.navigate(Routes.SETTINGS) })
}
composable(Routes.SETTINGS) { SettingsScreen(onBack = { nav.popBackStack() }) }
composable(Routes.SESSION) {
SessionScreen(
onBack = { nav.popBackStack() },
onAddExercise = { nav.navigate(Routes.PICKER) },
onSessionEnded = {
nav.popBackStack(Routes.HOME, inclusive = false)
},
)
}
composable(Routes.PICKER) {
ExercisePickerScreen(onDone = { nav.popBackStack() })
}
}
}
}
@Composable
private fun BottomBar(nav: NavHostController, currentRoute: String?, onStart: () -> Unit) {
Box {
NavigationBar {
tabs.forEachIndexed { index, tab ->
if (index == 2) {
// plats för mittknappen
NavigationBarItem(
selected = false,
onClick = {},
enabled = false,
icon = {},
label = { Text("") },
)
}
NavigationBarItem(
selected = currentRoute == tab.route,
onClick = {
nav.navigate(tab.route) {
popUpTo(Routes.HOME) { saveState = true }
launchSingleTop = true
restoreState = true
}
},
icon = { Icon(tab.icon, contentDescription = tab.label) },
label = { Text(tab.label) },
)
}
}
FloatingActionButton(
onClick = onStart,
modifier = Modifier
.align(Alignment.TopCenter)
.offset(y = (-6).dp),
containerColor = MaterialTheme.colorScheme.primary,
contentColor = MaterialTheme.colorScheme.onPrimary,
) {
Icon(Icons.Default.PlayArrow, contentDescription = "Starta pass")
}
}
}
@Composable
private fun MiniPlayer(restSecondsLeft: Int?, pendingCount: Int, onOpen: () -> Unit) {
Surface(
color = MaterialTheme.colorScheme.secondaryContainer,
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onOpen),
) {
Row(
modifier = Modifier.padding(horizontal = 16.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
Icons.Default.PlayArrow,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
)
Column(modifier = Modifier.weight(1f).padding(start = 10.dp)) {
Text("Pass pågår", style = MaterialTheme.typography.labelLarge)
val sub = buildString {
if (restSecondsLeft != null && restSecondsLeft > 0) {
append("Vila ${restSecondsLeft / 60}:${"%02d".format(restSecondsLeft % 60)} kvar")
} else {
append("Tryck för att fortsätta")
}
if (pendingCount > 0) append(" · $pendingCount osynkat")
}
Text(
sub,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Text(
"ÖPPNA",
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary,
)
}
}
}

View File

@@ -13,7 +13,6 @@ import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import eu.brassepc.fitnessdroid.data.AuthState import eu.brassepc.fitnessdroid.data.AuthState
import eu.brassepc.fitnessdroid.ui.home.HomeScreen
import eu.brassepc.fitnessdroid.ui.login.LoginScreen import eu.brassepc.fitnessdroid.ui.login.LoginScreen
@Composable @Composable
@@ -35,7 +34,7 @@ fun FitnessDroidApp(appViewModel: AppViewModel = viewModel(factory = AppViewMode
} }
} }
AuthState.LoggedOut -> LoginScreen() AuthState.LoggedOut -> LoginScreen()
is AuthState.LoggedIn -> HomeScreen() is AuthState.LoggedIn -> AppRoot()
} }
} }
} }

View File

@@ -0,0 +1,49 @@
package eu.brassepc.fitnessdroid.ui
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory
import eu.brassepc.fitnessdroid.data.GymRepository
import eu.brassepc.fitnessdroid.data.RestTimerController
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
class RootViewModel(
private val repo: GymRepository,
restTimer: RestTimerController,
) : ViewModel() {
val hasActiveSession = repo.activeSession.map { it != null }
val restState = restTimer.state
val pendingCount = repo.pendingCount
private val activeSessionState = repo.activeSession
.stateIn(viewModelScope, SharingStarted.Eagerly, null)
init {
repo.refreshAllAsync()
}
/** Mittknappen: öppna pågående pass, eller starta ett fritt pass. */
fun startOrResume(onReady: () -> Unit) {
viewModelScope.launch {
if (activeSessionState.value == null) {
repo.startFreeSession()
}
onReady()
}
}
companion object {
val Factory: ViewModelProvider.Factory = viewModelFactory {
initializer {
val c = appContainer()
RootViewModel(c.gymRepository, c.restTimer)
}
}
}
}

View File

@@ -0,0 +1,123 @@
package eu.brassepc.fitnessdroid.ui.common
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.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
/** En rad i kaloriuträkningen: en övning med MET, tilldelad tid och kcal. */
data class CalorieRow(
val name: String,
val metValue: Double?,
val seconds: Int,
val kcal: Double?,
)
/**
* Klient-side-uppskattning som speglar serverns ComputeCalories:
* passets tid fördelas jämnt över övningarna (tidssatta set utgör golv
* för sin övning) och per övning gäller kcal = MET × kroppsvikt × timmar.
*/
fun estimateCalories(
exercises: List<Triple<String, Double, Int>>, // (namn, MET, golv-sekunder från tidssatta set)
bodyWeightKg: Double?,
totalSeconds: Int,
): Pair<Double?, List<CalorieRow>> {
if (exercises.isEmpty()) return null to emptyList()
val floorsSum = exercises.sumOf { it.third }
val leftover = (totalSeconds - floorsSum).coerceAtLeast(0)
val perAuto = leftover / exercises.size.toDouble()
val rows = exercises.map { (name, met, floor) ->
val sec = (floor + perAuto).toInt()
CalorieRow(
name = name,
metValue = met,
seconds = sec,
kcal = bodyWeightKg?.let { met * it * (sec / 3600.0) },
)
}
val total = if (bodyWeightKg == null) null else rows.sumOf { it.kcal ?: 0.0 }
return total to rows
}
@Composable
fun CalorieInfoDialog(
rows: List<CalorieRow>,
bodyWeightKg: Double?,
totalKcal: Double?,
onDismiss: () -> Unit,
) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Så räknas kalorierna") },
text = {
Column(
modifier = Modifier.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
Text(
"Per övning: kcal = MET × kroppsvikt (kg) × tid (timmar). " +
"Passets tid fördelas jämnt över övningarna, men set med " +
"uppmätt tid räknas alltid in i sin övning.",
style = MaterialTheme.typography.bodyMedium,
)
if (bodyWeightKg == null) {
Text(
"Ingen kroppsvikt satt i profilen — därför kan inga " +
"kalorier beräknas. Sätt den på webben under Profil.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.error,
)
} else {
Text(
"Din kroppsvikt: ${bodyWeightKg.compact()} kg",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
rows.forEach { row ->
Row(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.weight(1f)) {
Text(row.name, style = MaterialTheme.typography.bodyMedium)
Text(
buildString {
append("MET ${row.metValue?.compact() ?: "?"}")
append(" · ${row.seconds / 60} min")
},
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Text(
row.kcal?.let { "${it.toInt()} kcal" } ?: "",
style = MaterialTheme.typography.bodyMedium,
)
}
}
if (totalKcal != null) {
Row(modifier = Modifier.fillMaxWidth().padding(top = 4.dp)) {
Text(
"Totalt",
style = MaterialTheme.typography.titleSmall,
modifier = Modifier.weight(1f),
)
Text("${totalKcal.toInt()} kcal", style = MaterialTheme.typography.titleSmall)
}
}
}
},
confirmButton = {
TextButton(onClick = onDismiss) { Text("Stäng") }
},
)
}

View File

@@ -0,0 +1,18 @@
package eu.brassepc.fitnessdroid.ui.common
import eu.brassepc.fitnessdroid.data.HistorySet
fun Double.compact(): String =
if (this % 1.0 == 0.0) toInt().toString() else "%.1f".format(this)
/** "80 kg × 8 · RPE 8" — samma format överallt där set visas. */
fun HistorySet.describe(): String = buildString {
weight?.let { append("${it.compact()} kg") }
reps?.let {
if (isNotEmpty()) append(" × $it") else append("$it reps")
}
durationSeconds?.let { if (isNotEmpty()) append(" · ${it}s") else append("${it}s") }
distanceMeters?.let { if (isNotEmpty()) append(" · ${it.compact()} m") else append("${it.compact()} m") }
rpe?.let { append(" · RPE ${it.compact()}") }
if (isWarmup) append(" · uppvärmning")
}

View File

@@ -0,0 +1,50 @@
package eu.brassepc.fitnessdroid.ui.common
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.DirectionsRun
import androidx.compose.material.icons.filled.Accessibility
import androidx.compose.material.icons.filled.AirlineSeatLegroomExtra
import androidx.compose.material.icons.filled.FavoriteBorder
import androidx.compose.material.icons.filled.FitnessCenter
import androidx.compose.material.icons.filled.FrontHand
import androidx.compose.material.icons.filled.Grid4x4
import androidx.compose.material.icons.filled.SportsGymnastics
import androidx.compose.material.icons.filled.SportsMartialArts
import androidx.compose.ui.graphics.vector.ImageVector
/**
* Ikon per muskelgrupp. I första hand via serverns iconKey
* (API-tillägget MuscleGroup.iconKey), i andra hand namnheuristik.
*/
object MuscleIcons {
fun forKey(iconKey: String?): ImageVector? = when (iconKey?.lowercase()) {
"chest" -> Icons.Default.FitnessCenter
"back" -> Icons.Default.SportsGymnastics
"legs" -> Icons.Default.AirlineSeatLegroomExtra
"shoulders" -> Icons.Default.SportsMartialArts
"arms" -> Icons.Default.FrontHand
"core" -> Icons.Default.Grid4x4
"cardio" -> Icons.AutoMirrored.Filled.DirectionsRun
"fullbody" -> Icons.Default.Accessibility
else -> null
}
fun forGroupName(name: String?): ImageVector {
val n = name?.lowercase().orEmpty()
return when {
"bröst" in n || "chest" in n -> Icons.Default.FitnessCenter
"rygg" in n || "back" in n || "lat" in n -> Icons.Default.SportsGymnastics
"ben" in n || "leg" in n || "quad" in n || "vad" in n -> Icons.Default.AirlineSeatLegroomExtra
"axl" in n || "shoulder" in n || "delt" in n -> Icons.Default.SportsMartialArts
"arm" in n || "bicep" in n || "tricep" in n -> Icons.Default.FrontHand
"mage" in n || "core" in n || "abs" in n || "bål" in n -> Icons.Default.Grid4x4
"kondition" in n || "cardio" in n || "löp" in n -> Icons.AutoMirrored.Filled.DirectionsRun
"hel" in n || "full" in n -> Icons.Default.Accessibility
else -> Icons.Default.FavoriteBorder
}
}
fun resolve(iconKey: String?, groupName: String?): ImageVector =
forKey(iconKey) ?: forGroupName(groupName)
}

View File

@@ -0,0 +1,245 @@
package eu.brassepc.fitnessdroid.ui.history
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.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.Card
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
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.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory
import eu.brassepc.fitnessdroid.data.GymRepository
import eu.brassepc.fitnessdroid.data.SessionDetail
import eu.brassepc.fitnessdroid.data.SessionStats
import eu.brassepc.fitnessdroid.ui.appContainer
import eu.brassepc.fitnessdroid.ui.common.CalorieInfoDialog
import eu.brassepc.fitnessdroid.ui.common.CalorieRow
import eu.brassepc.fitnessdroid.ui.common.describe
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.launch
sealed interface DetailUiState {
data object Loading : DetailUiState
data class Error(val message: String) : DetailUiState
data class Ready(
val detail: SessionDetail,
val stats: SessionStats?,
val bodyWeightKg: Double?,
) : DetailUiState
}
class HistoryDetailViewModel(private val repo: GymRepository) : ViewModel() {
val uiState = MutableStateFlow<DetailUiState>(DetailUiState.Loading)
private var loadedId: Int? = null
fun load(id: Int) {
if (loadedId == id && uiState.value is DetailUiState.Ready) return
loadedId = id
uiState.value = DetailUiState.Loading
viewModelScope.launch {
uiState.value = try {
DetailUiState.Ready(
detail = repo.fetchSessionDetail(id),
stats = runCatching { repo.fetchSessionStats(id) }.getOrNull(),
bodyWeightKg = repo.bodyWeightKg(),
)
} catch (e: Exception) {
DetailUiState.Error("Kunde inte hämta passet — offline?")
}
}
}
companion object {
val Factory: ViewModelProvider.Factory = viewModelFactory {
initializer { HistoryDetailViewModel(appContainer().gymRepository) }
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun HistoryDetailScreen(
sessionId: Int,
onBack: () -> Unit,
viewModel: HistoryDetailViewModel = viewModel(factory = HistoryDetailViewModel.Factory),
) {
LaunchedEffect(sessionId) { viewModel.load(sessionId) }
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
Scaffold(
topBar = {
TopAppBar(
title = {
Text((uiState as? DetailUiState.Ready)?.detail?.name ?: "Pass")
},
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Tillbaka")
}
},
)
},
) { padding ->
when (val state = uiState) {
is DetailUiState.Loading -> {
Box(
modifier = Modifier.fillMaxSize().padding(padding),
contentAlignment = Alignment.Center,
) { CircularProgressIndicator() }
}
is DetailUiState.Error -> {
Box(
modifier = Modifier.fillMaxSize().padding(padding),
contentAlignment = Alignment.Center,
) {
Text(state.message, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
is DetailUiState.Ready -> {
val d = state.detail
val stats = state.stats
var showCalorieInfo by remember { mutableStateOf(false) }
if (showCalorieInfo && stats != null) {
CalorieInfoDialog(
rows = stats.breakdown.map {
CalorieRow(
name = it.name,
metValue = it.metValue,
seconds = it.allocatedSeconds ?: 0,
kcal = it.estimatedCalories,
)
},
bodyWeightKg = state.bodyWeightKg,
totalKcal = stats.estimatedCalories,
onDismiss = { showCalorieInfo = false },
)
}
val kcalByName = remember(stats) {
stats?.breakdown?.groupBy { it.name }?.mapValues { (_, v) ->
ArrayDeque(v)
} ?: emptyMap()
}
LazyColumn(
modifier = Modifier.fillMaxSize().padding(padding),
contentPadding = androidx.compose.foundation.layout.PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
item {
Card(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.padding(14.dp)) {
Text(
buildString {
append(d.date.take(10))
(d.durationMinutes ?: stats?.durationMinutes)?.let {
append(" · ")
append(if (it >= 60) "${it / 60}h ${it % 60}m" else "$it min")
}
if (stats != null) {
append(" · ${stats.totalSets} set · ${stats.totalVolumeKg.toInt()} kg volym")
}
},
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
(stats?.estimatedCalories ?: d.calories)
?.let { "${it.toInt()} kcal" }
?: "Inga kalorier (kroppsvikt saknas)",
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.weight(1f),
)
if (stats != null) {
TextButton(onClick = { showCalorieInfo = true }) {
Text("Hur räknas detta?")
}
}
}
d.notes?.takeIf { it.isNotBlank() }?.let {
Text(it, style = MaterialTheme.typography.bodyMedium)
}
}
}
}
items(d.exercises) { exercise ->
val exerciseKcal = kcalByName[exercise.name]?.removeFirstOrNull()
Card(modifier = Modifier.fillMaxWidth()) {
Column(
modifier = Modifier.padding(14.dp),
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
Row {
Text(
exercise.name,
style = MaterialTheme.typography.titleSmall,
modifier = Modifier.weight(1f),
)
exerciseKcal?.estimatedCalories?.let {
Text(
"${it.toInt()} kcal",
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary,
)
}
}
exercise.sets.forEach { set ->
Row {
Text(
"Set ${set.order}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(end = 10.dp),
)
Text(
set.describe(),
style = MaterialTheme.typography.bodySmall,
)
}
}
if (exercise.sets.isEmpty()) {
Text(
"Inga set loggade",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
}
}
}
}
}

View File

@@ -0,0 +1,145 @@
package eu.brassepc.fitnessdroid.ui.history
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.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory
import eu.brassepc.fitnessdroid.data.GymRepository
import eu.brassepc.fitnessdroid.data.HistoryItem
import eu.brassepc.fitnessdroid.ui.appContainer
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.launch
sealed interface HistoryUiState {
data object Loading : HistoryUiState
data class Error(val message: String) : HistoryUiState
data class Ready(val items: List<HistoryItem>) : HistoryUiState
}
class HistoryViewModel(private val repo: GymRepository) : ViewModel() {
val uiState = MutableStateFlow<HistoryUiState>(HistoryUiState.Loading)
init { load() }
fun load() {
uiState.value = HistoryUiState.Loading
viewModelScope.launch {
uiState.value = try {
HistoryUiState.Ready(repo.fetchHistory())
} catch (e: Exception) {
HistoryUiState.Error("Kunde inte hämta historiken — offline?")
}
}
}
companion object {
val Factory: ViewModelProvider.Factory = viewModelFactory {
initializer { HistoryViewModel(appContainer().gymRepository) }
}
}
}
@OptIn(androidx.compose.material3.ExperimentalMaterial3Api::class)
@Composable
fun HistoryScreen(
onOpenSession: (Int) -> Unit,
viewModel: HistoryViewModel = viewModel(factory = HistoryViewModel.Factory),
) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
when (val state = uiState) {
is HistoryUiState.Loading -> {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
}
}
is HistoryUiState.Error -> {
Column(
modifier = Modifier.fillMaxSize().padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
Text(state.message, color = MaterialTheme.colorScheme.onSurfaceVariant)
Button(onClick = viewModel::load, modifier = Modifier.padding(top = 12.dp)) {
Text("Försök igen")
}
}
}
is HistoryUiState.Ready -> {
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = androidx.compose.foundation.layout.PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
item {
Text("Passhistorik", style = MaterialTheme.typography.headlineSmall)
}
items(state.items, key = { it.id }) { item ->
Card(
onClick = { onOpenSession(item.id) },
modifier = Modifier.fillMaxWidth(),
) {
Column(modifier = Modifier.padding(14.dp)) {
Row {
Text(
item.name,
style = MaterialTheme.typography.titleSmall,
modifier = Modifier.weight(1f),
)
Text(
item.date.take(10),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Text(
buildString {
item.durationMinutes?.let {
append(if (it >= 60) "${it / 60}h ${it % 60}m" else "$it min")
append(" · ")
}
append("${item.exerciseCount} övningar · ${item.setCount} set")
if (item.volumeKg > 0) append(" · ${item.volumeKg.toInt()} kg volym")
item.calories?.let { append(" · ${it.toInt()} kcal") }
},
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 4.dp),
)
}
}
}
if (state.items.isEmpty()) {
item {
Text(
"Inga avslutade pass än — dags att ändra på det!",
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
}
}

View File

@@ -1,23 +1,29 @@
package eu.brassepc.fitnessdroid.ui.home package eu.brassepc.fitnessdroid.ui.home
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Logout import androidx.compose.material.icons.filled.CloudDone
import androidx.compose.material.icons.filled.CloudOff
import androidx.compose.material.icons.filled.CloudUpload
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.Star
import androidx.compose.material.icons.filled.SystemUpdate
import androidx.compose.material3.AssistChip
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.CenterAlignedTopAppBar
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@@ -26,95 +32,190 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import eu.brassepc.fitnessdroid.data.SyncStatus
@OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun HomeScreen(viewModel: HomeViewModel = viewModel(factory = HomeViewModel.Factory)) { fun HomeScreen(
onOpenSession: () -> Unit,
onOpenHistory: () -> Unit,
viewModel: HomeViewModel = viewModel(factory = HomeViewModel.Factory),
) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle() val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val updateState by viewModel.updateState.collectAsStateWithLifecycle()
val displayName by viewModel.displayName.collectAsStateWithLifecycle()
Scaffold(
topBar = {
CenterAlignedTopAppBar(
title = { Text("FitnessDroid") },
actions = {
IconButton(onClick = viewModel::logout) {
Icon(
imageVector = Icons.AutoMirrored.Filled.Logout,
contentDescription = "Logga ut",
)
}
},
)
},
) { padding ->
when (val state = uiState) {
is HomeUiState.Loading -> {
Box(
modifier = Modifier
.fillMaxSize()
.padding(padding),
contentAlignment = Alignment.Center,
) {
CircularProgressIndicator()
}
}
is HomeUiState.Error -> {
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.padding(padding) .verticalScroll(rememberScrollState())
.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
Text(state.message, color = MaterialTheme.colorScheme.error)
Button(onClick = viewModel::load, modifier = Modifier.padding(top = 16.dp)) {
Text("Försök igen")
}
}
}
is HomeUiState.Ready -> {
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.padding(16.dp), .padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp),
) { ) {
Row(verticalAlignment = Alignment.CenterVertically) {
Column(modifier = Modifier.weight(1f)) {
Text( Text(
"Hej ${state.profile.displayName ?: state.profile.username}!", "Tjena ${displayName ?: uiState.username}",
style = MaterialTheme.typography.headlineSmall, style = MaterialTheme.typography.headlineSmall,
) )
Card(modifier = Modifier.fillMaxWidth()) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Text("Profil", style = MaterialTheme.typography.titleMedium)
ProfileRow("Användarnamn", state.profile.username)
state.profile.email?.let { ProfileRow("E-post", it) }
state.profile.bodyWeightKg?.let { ProfileRow("Kroppsvikt", "$it kg") }
}
}
Text( Text(
"Pass, loggning, statistik och PB kommer i nästa steg.", "Redo för ett pass?",
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
} }
SyncChip(status = uiState.syncStatus, pending = uiState.pendingCount)
}
updateState?.let { update ->
Card(modifier = Modifier.fillMaxWidth()) {
Row(
modifier = Modifier.padding(14.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
Icons.Default.SystemUpdate,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
)
Column(modifier = Modifier.weight(1f).padding(start = 10.dp)) {
when (update) {
is UpdateState.Available -> {
Text(
"Ny version: ${update.info.versionName}",
style = MaterialTheme.typography.titleSmall,
)
Text(
"Laddas ner från släppsidan och installeras ovanpå",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
is UpdateState.Downloading -> {
Text("Laddar ner …", style = MaterialTheme.typography.titleSmall)
androidx.compose.material3.LinearProgressIndicator(
progress = { update.progress },
modifier = Modifier.fillMaxWidth().padding(top = 6.dp),
)
}
is UpdateState.Failed -> {
Text(
update.message,
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.error,
)
} }
} }
} }
if (update !is UpdateState.Downloading) {
Button(onClick = viewModel::startUpdate) {
Text(if (update is UpdateState.Failed) "Igen" else "Uppdatera")
}
}
}
}
}
if (uiState.hasActiveSession) {
Card(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onOpenSession),
) {
Row(
modifier = Modifier.padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
Icons.Default.PlayArrow,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
)
Text(
"Pass pågår — tryck för att fortsätta",
modifier = Modifier.padding(start = 10.dp),
style = MaterialTheme.typography.titleSmall,
)
}
}
} else {
Button(
onClick = { viewModel.startFree(onOpenSession) },
modifier = Modifier.fillMaxWidth(),
) {
Icon(Icons.Default.PlayArrow, contentDescription = null)
Text("Starta fritt pass", modifier = Modifier.padding(start = 6.dp))
}
}
if (uiState.startCards.isNotEmpty()) {
Text("Favoritpass & mallar", style = MaterialTheme.typography.titleMedium)
LazyRow(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
items(uiState.startCards, key = { it.key }) { card ->
Card(
modifier = Modifier
.width(170.dp)
.clickable(enabled = !uiState.hasActiveSession) {
viewModel.startFromCard(card, onOpenSession)
},
) {
Column(modifier = Modifier.padding(12.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
Icons.Default.Star,
contentDescription = null,
tint = MaterialTheme.colorScheme.tertiary,
modifier = Modifier.padding(end = 6.dp),
)
Text(
card.title,
style = MaterialTheme.typography.titleSmall,
maxLines = 1,
)
}
Text(
card.subtitle,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 4.dp),
)
Text(
if (uiState.hasActiveSession) "Pass pågår" else "▶ Starta",
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(top = 10.dp),
)
}
}
}
}
}
AssistChip(
onClick = onOpenHistory,
label = { Text("Passhistorik") },
)
}
} }
@Composable @Composable
private fun ProfileRow(label: String, value: String) { private fun SyncChip(status: SyncStatus, pending: Int) {
Row(modifier = Modifier.fillMaxWidth()) { val (icon, text) = when {
status == SyncStatus.OFFLINE -> Icons.Default.CloudOff to "Offline · $pending väntar"
status == SyncStatus.ERROR -> Icons.Default.CloudOff to "Synkfel"
pending > 0 -> Icons.Default.CloudUpload to "$pending osynkat"
else -> Icons.Default.CloudDone to "Synkat"
}
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
icon,
contentDescription = text,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(end = 4.dp),
)
Text( Text(
label, text,
modifier = Modifier.weight(1f), style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
Text(value)
} }
} }

View File

@@ -6,54 +6,100 @@ import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.initializer import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory import androidx.lifecycle.viewmodel.viewModelFactory
import eu.brassepc.fitnessdroid.data.AuthRepository import eu.brassepc.fitnessdroid.data.AuthRepository
import eu.brassepc.fitnessdroid.data.GraphQlException import eu.brassepc.fitnessdroid.data.AuthState
import eu.brassepc.fitnessdroid.data.GymApi import eu.brassepc.fitnessdroid.data.GymRepository
import eu.brassepc.fitnessdroid.data.Profile import eu.brassepc.fitnessdroid.data.SyncStatus
import eu.brassepc.fitnessdroid.data.local.CachedStartCard
import eu.brassepc.fitnessdroid.ui.appContainer import eu.brassepc.fitnessdroid.ui.appContainer
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
sealed interface HomeUiState { data class HomeUiState(
data object Loading : HomeUiState val username: String = "",
data class Error(val message: String) : HomeUiState val hasActiveSession: Boolean = false,
data class Ready(val profile: Profile) : HomeUiState val startCards: List<CachedStartCard> = emptyList(),
val syncStatus: SyncStatus = SyncStatus.SYNCED,
val pendingCount: Int = 0,
)
sealed interface UpdateState {
data class Available(val info: eu.brassepc.fitnessdroid.data.UpdateInfo) : UpdateState
data class Downloading(val progress: Float) : UpdateState
data class Failed(val message: String) : UpdateState
} }
class HomeViewModel( class HomeViewModel(
private val gymApi: GymApi, private val repo: GymRepository,
private val auth: AuthRepository, auth: AuthRepository,
private val updateChecker: eu.brassepc.fitnessdroid.data.UpdateChecker,
) : ViewModel() { ) : ViewModel() {
private val _uiState = MutableStateFlow<HomeUiState>(HomeUiState.Loading) val updateState = kotlinx.coroutines.flow.MutableStateFlow<UpdateState?>(null)
val uiState: StateFlow<HomeUiState> = _uiState
/** Helnamn från profilen — trevligare hälsning än användarnamnet. */
val displayName = kotlinx.coroutines.flow.MutableStateFlow<String?>(null)
init { init {
load() viewModelScope.launch {
displayName.value = repo.displayName()
updateChecker.check()?.let { updateState.value = UpdateState.Available(it) }
}
} }
fun load() { fun startUpdate() {
_uiState.value = HomeUiState.Loading val info = (updateState.value as? UpdateState.Available)?.info ?: return
viewModelScope.launch { viewModelScope.launch {
try { try {
_uiState.value = HomeUiState.Ready(gymApi.myProfile()) updateState.value = UpdateState.Downloading(0f)
} catch (e: GraphQlException) { val file = updateChecker.download(info) { p ->
_uiState.value = HomeUiState.Error(e.message ?: "Något gick fel") updateState.value = UpdateState.Downloading(p)
}
updateChecker.install(file)
// Tillbaka till "tillgänglig" om installationen avbryts.
updateState.value = UpdateState.Available(info)
} catch (e: Exception) { } catch (e: Exception) {
_uiState.value = HomeUiState.Error("Kunde inte nå servern") updateState.value = UpdateState.Failed("Nedladdningen misslyckades — försök igen")
} }
} }
} }
fun logout() { val uiState = combine(
viewModelScope.launch { auth.logout() } repo.activeSession,
repo.startCards,
repo.syncStatus,
repo.pendingCount,
auth.state,
) { session, cards, sync, pending, authState ->
HomeUiState(
username = (authState as? AuthState.LoggedIn)?.username ?: "",
hasActiveSession = session != null,
startCards = cards,
syncStatus = sync,
pendingCount = pending,
)
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), HomeUiState())
fun startFree(onReady: () -> Unit) {
viewModelScope.launch {
repo.startFreeSession()
onReady()
}
}
fun startFromCard(card: CachedStartCard, onReady: () -> Unit) {
viewModelScope.launch {
repo.startFromCard(card)
onReady()
}
} }
companion object { companion object {
val Factory: ViewModelProvider.Factory = viewModelFactory { val Factory: ViewModelProvider.Factory = viewModelFactory {
initializer { initializer {
val container = appContainer() val c = appContainer()
HomeViewModel(container.gymApi, container.authRepository) HomeViewModel(c.gymRepository, c.authRepository, c.updateChecker)
} }
} }
} }

View File

@@ -0,0 +1,208 @@
package eu.brassepc.fitnessdroid.ui.picker
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.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.Star
import androidx.compose.material.icons.filled.StarBorder
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilterChip
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import eu.brassepc.fitnessdroid.ui.common.MuscleIcons
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ExercisePickerScreen(
onDone: () -> Unit,
viewModel: PickerViewModel = viewModel(factory = PickerViewModel.Factory),
) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
Scaffold(
topBar = {
TopAppBar(
title = { Text("Lägg till övning") },
navigationIcon = {
IconButton(onClick = onDone) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Tillbaka")
}
},
)
},
) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
OutlinedTextField(
value = uiState.query,
onValueChange = viewModel::onQueryChange,
placeholder = { Text("Sök övning …") },
leadingIcon = { Icon(Icons.Default.Search, contentDescription = null) },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
item {
FilterChip(
selected = uiState.selectedGroupId == null,
onClick = { viewModel.selectGroup(null) },
label = { Text("Alla") },
)
}
items(uiState.groups, key = { it.id }) { group ->
FilterChip(
selected = uiState.selectedGroupId == group.id,
onClick = { viewModel.selectGroup(group.id) },
label = { Text(group.name) },
leadingIcon = {
Icon(
MuscleIcons.resolve(group.iconKey, group.name),
contentDescription = null,
modifier = Modifier.size(16.dp),
)
},
)
}
}
if (uiState.musclesInGroup.isNotEmpty()) {
LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
item {
FilterChip(
selected = uiState.selectedMuscleId == null,
onClick = { viewModel.selectMuscle(null) },
label = { Text("Alla i gruppen") },
)
}
items(uiState.musclesInGroup, key = { it.id }) { muscle ->
FilterChip(
selected = uiState.selectedMuscleId == muscle.id,
onClick = { viewModel.selectMuscle(muscle.id) },
label = { Text(muscle.name) },
)
}
}
}
LazyColumn(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
items(uiState.exercises, key = { it.id }) { row ->
Surface(
onClick = { viewModel.pick(row.id, onDone) },
shape = MaterialTheme.shapes.medium,
color = MaterialTheme.colorScheme.surface,
modifier = Modifier.fillMaxWidth(),
) {
Row(
modifier = Modifier.padding(horizontal = 8.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Surface(
shape = CircleShape,
color = MaterialTheme.colorScheme.surfaceVariant,
modifier = Modifier.size(38.dp),
) {
Box(contentAlignment = Alignment.Center) {
Icon(
row.icon,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(20.dp),
)
}
}
Column(
modifier = Modifier
.weight(1f)
.padding(horizontal = 10.dp),
) {
Text(row.name, style = MaterialTheme.typography.titleSmall)
Text(
row.subtitle,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
row.badges.forEach { badge ->
Text(
badge,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(start = 6.dp),
)
}
IconButton(onClick = { viewModel.toggleFavorite(row.id, !row.favorite) }) {
Icon(
if (row.favorite) Icons.Default.Star else Icons.Default.StarBorder,
contentDescription = "Favorit",
tint = if (row.favorite) {
MaterialTheme.colorScheme.tertiary
} else {
MaterialTheme.colorScheme.onSurfaceVariant
},
)
}
}
}
}
if (uiState.exercises.isEmpty()) {
item {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
if (uiState.query.isBlank() && uiState.selectedGroupId == null) {
"Övningslistan är tom — hämtar från servern …"
} else {
"Inga övningar matchar — testa ett annat filter."
},
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
androidx.compose.material3.FilledTonalButton(
onClick = viewModel::refresh,
modifier = Modifier.padding(top = 12.dp),
) { Text("Uppdatera från servern") }
}
}
}
}
}
}
}

View File

@@ -0,0 +1,142 @@
package eu.brassepc.fitnessdroid.ui.picker
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory
import eu.brassepc.fitnessdroid.data.GymRepository
import eu.brassepc.fitnessdroid.data.local.CachedMuscle
import eu.brassepc.fitnessdroid.data.local.CachedMuscleGroup
import eu.brassepc.fitnessdroid.ui.appContainer
import eu.brassepc.fitnessdroid.ui.common.MuscleIcons
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
data class ExerciseRow(
val id: Int,
val name: String,
val subtitle: String,
val badges: List<String>,
val favorite: Boolean,
val icon: ImageVector,
)
data class PickerUiState(
val query: String = "",
val groups: List<CachedMuscleGroup> = emptyList(),
val musclesInGroup: List<CachedMuscle> = emptyList(),
val selectedGroupId: Int? = null,
val selectedMuscleId: Int? = null,
val exercises: List<ExerciseRow> = emptyList(),
)
class PickerViewModel(private val repo: GymRepository) : ViewModel() {
private val query = MutableStateFlow("")
private val selectedGroup = MutableStateFlow<Int?>(null)
private val selectedMuscle = MutableStateFlow<Int?>(null)
init {
// Tom cache (t.ex. första start eller misslyckad förra hämtning)?
// Försök fylla den direkt när väljaren öppnas.
viewModelScope.launch {
if (repo.exerciseTypes.first().isEmpty()) repo.refreshAllAsync()
}
}
fun refresh() = repo.refreshAllAsync()
private data class Filters(val query: String, val groupId: Int?, val muscleId: Int?)
private val filters = combine(query, selectedGroup, selectedMuscle) { q, g, m -> Filters(q, g, m) }
val uiState = combine(
repo.exerciseTypes,
repo.muscleGroups,
repo.muscles,
filters,
) { types, groups, muscles, f ->
val musclesByGroup = muscles.groupBy { it.muscleGroupId }
val groupByMuscleId = muscles.associate { it.id to it.muscleGroupId }
val groupNameById = groups.associate { it.id to it.name }
val groupIconKeyById = groups.associate { it.id to it.iconKey }
val filtered = types.filter { t ->
val muscleIds = t.muscleIds.split(",").mapNotNull { it.trim().toIntOrNull() }
val inGroup = f.groupId == null ||
muscleIds.any { groupByMuscleId[it] == f.groupId }
val inMuscle = f.muscleId == null || f.muscleId in muscleIds
val matches = f.query.isBlank() || t.name.contains(f.query.trim(), ignoreCase = true)
inGroup && inMuscle && matches
}
PickerUiState(
query = f.query,
groups = groups,
musclesInGroup = f.groupId?.let { musclesByGroup[it] }.orEmpty(),
selectedGroupId = f.groupId,
selectedMuscleId = f.muscleId,
exercises = filtered.map { t ->
val muscleIds = t.muscleIds.split(",").mapNotNull { it.trim().toIntOrNull() }
val primaryGroupId = muscleIds.firstNotNullOfOrNull { groupByMuscleId[it] }
val groupNames = muscleIds.mapNotNull { groupByMuscleId[it] }
.distinct().mapNotNull { groupNameById[it] }
ExerciseRow(
id = t.id,
name = t.name,
subtitle = buildString {
append(groupNames.joinToString(" · ").ifEmpty { "Övrigt" })
t.lastWeight?.let { append(" · senast ${it.compact()} kg") }
},
badges = buildList {
if (t.tracksWeight) add("KG")
if (t.tracksReps) add("REPS")
if (t.tracksDuration) add("TID")
if (t.tracksDistance) add("M")
},
favorite = t.isFavorite,
icon = MuscleIcons.resolve(
primaryGroupId?.let { groupIconKeyById[it] },
primaryGroupId?.let { groupNameById[it] },
),
)
},
)
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), PickerUiState())
fun onQueryChange(value: String) { query.value = value }
fun selectGroup(id: Int?) {
selectedGroup.value = id
selectedMuscle.value = null
}
fun selectMuscle(id: Int?) { selectedMuscle.value = id }
fun toggleFavorite(exerciseTypeId: Int, favorite: Boolean) =
repo.toggleFavoriteExercise(exerciseTypeId, favorite)
fun pick(exerciseTypeId: Int, onDone: () -> Unit) {
viewModelScope.launch {
val sessionId = repo.activeSession.first()?.id ?: return@launch
repo.addExercise(sessionId, exerciseTypeId)
onDone()
}
}
companion object {
val Factory: ViewModelProvider.Factory = viewModelFactory {
initializer { PickerViewModel(appContainer().gymRepository) }
}
}
}
private fun Double.compact(): String =
if (this % 1.0 == 0.0) toInt().toString() else "%.1f".format(this)

View File

@@ -0,0 +1,422 @@
package eu.brassepc.fitnessdroid.ui.profile
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.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Logout
import androidx.compose.material.icons.filled.MonitorWeight
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory
import eu.brassepc.fitnessdroid.data.AuthRepository
import eu.brassepc.fitnessdroid.data.BodyMeasurement
import eu.brassepc.fitnessdroid.data.GymApi
import eu.brassepc.fitnessdroid.data.GymRepository
import eu.brassepc.fitnessdroid.data.Profile
import eu.brassepc.fitnessdroid.ui.appContainer
import eu.brassepc.fitnessdroid.ui.common.compact
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.launch
class ProfileViewModel(
private val gymApi: GymApi,
private val auth: AuthRepository,
private val repo: GymRepository,
) : ViewModel() {
val profile = MutableStateFlow<Profile?>(null)
val measurements = MutableStateFlow<List<BodyMeasurement>>(emptyList())
val message = MutableStateFlow<String?>(null)
init { load() }
fun load() {
viewModelScope.launch {
runCatching { profile.value = gymApi.myProfile() }
runCatching { measurements.value = repo.fetchBodyMeasurements().reversed() }
}
}
fun addWeight(weightKg: Double, dateIso: String?, muscle: Double?, fat: Double?, water: Double?) {
viewModelScope.launch {
try {
repo.addBodyMeasurement(weightKg, dateIso, muscle, fat, water)
message.value = "Vikten sparad"
load()
} catch (e: Exception) {
message.value = "Kunde inte spara — offline?"
}
}
}
fun updateMeasurement(
id: String, weightKg: Double, dateIso: String?,
muscle: Double?, fat: Double?, water: Double?,
) {
viewModelScope.launch {
try {
// -1 rensar fältet på servern om användaren tömt det
repo.updateBodyMeasurement(
id, weightKg, dateIso,
muscle ?: -1.0, fat ?: -1.0, water ?: -1.0,
)
load()
} catch (e: Exception) {
message.value = "Kunde inte uppdatera — offline?"
}
}
}
fun deleteMeasurement(id: String) {
viewModelScope.launch {
try {
repo.deleteBodyMeasurement(id)
load()
} catch (e: Exception) {
message.value = "Kunde inte ta bort — offline?"
}
}
}
fun logout() {
viewModelScope.launch { auth.logout() }
}
companion object {
val Factory: ViewModelProvider.Factory = viewModelFactory {
initializer {
val c = appContainer()
ProfileViewModel(c.gymApi, c.authRepository, c.gymRepository)
}
}
}
}
@Composable
fun ProfileScreen(
onOpenSettings: () -> Unit,
viewModel: ProfileViewModel = viewModel(factory = ProfileViewModel.Factory),
) {
val profile by viewModel.profile.collectAsStateWithLifecycle()
val measurements by viewModel.measurements.collectAsStateWithLifecycle()
val message by viewModel.message.collectAsStateWithLifecycle()
var showAddWeight by remember { mutableStateOf(false) }
var editTarget by remember { mutableStateOf<BodyMeasurement?>(null) }
if (showAddWeight) {
WeightDialog(
title = "Uppdatera kroppsvikt",
initial = profile?.bodyWeightKg,
initialDate = java.time.LocalDate.now(),
initialMuscle = null,
initialFat = null,
initialWater = null,
onSave = { weight, dateIso, muscle, fat, water ->
viewModel.addWeight(weight, dateIso, muscle, fat, water)
showAddWeight = false
},
onDismiss = { showAddWeight = false },
)
}
editTarget?.let { m ->
WeightDialog(
title = "Rätta mätning",
initial = m.weightKg,
initialDate = runCatching { java.time.LocalDate.parse(m.date.take(10)) }
.getOrElse { java.time.LocalDate.now() },
initialMuscle = m.musclePercent,
initialFat = m.fatPercent,
initialWater = m.waterPercent,
onSave = { weight, dateIso, muscle, fat, water ->
viewModel.updateMeasurement(m.id, weight, dateIso, muscle, fat, water)
editTarget = null
},
onDelete = {
viewModel.deleteMeasurement(m.id)
editTarget = null
},
onDismiss = { editTarget = null },
)
}
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(14.dp),
) {
Text("Profil", style = MaterialTheme.typography.headlineSmall)
Card(modifier = Modifier.fillMaxWidth()) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
ProfileRow("Användarnamn", profile?.username ?: "")
profile?.displayName?.let { ProfileRow("Namn", it) }
profile?.email?.let { ProfileRow("E-post", it) }
}
}
Card(modifier = Modifier.fillMaxWidth()) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
Icons.Default.MonitorWeight,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
)
Column(modifier = Modifier.weight(1f).padding(start = 10.dp)) {
Text("Kroppsvikt", style = MaterialTheme.typography.titleSmall)
Text(
profile?.bodyWeightKg?.let { "${it.compact()} kg" } ?: "Inte satt",
style = MaterialTheme.typography.headlineSmall,
)
}
Button(onClick = { showAddWeight = true }) { Text("Uppdatera") }
}
message?.let {
Text(
it,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
if (measurements.isNotEmpty()) {
Text(
"Historik (tryck för att rätta)",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
measurements.take(8).forEach { m ->
Row(
modifier = Modifier
.fillMaxWidth()
.clickable { editTarget = m }
.padding(vertical = 4.dp),
) {
Text(
m.date.take(10),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(1f),
)
val composition = listOfNotNull(
m.musclePercent?.let { "M ${it.compact()}%" },
m.fatPercent?.let { "F ${it.compact()}%" },
m.waterPercent?.let { "V ${it.compact()}%" },
).joinToString(" · ")
if (composition.isNotEmpty()) {
Text(
composition,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(end = 8.dp),
)
}
Text(
"${m.weightKg.compact()} kg",
style = MaterialTheme.typography.bodySmall,
)
}
}
}
}
}
Card(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onOpenSettings),
) {
Row(
modifier = Modifier.padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(Icons.Default.Settings, contentDescription = null)
Column(modifier = Modifier.weight(1f).padding(start = 12.dp)) {
Text("Inställningar", style = MaterialTheme.typography.titleSmall)
Text(
"Vilotimer, viktsteg, stänger, inloggning",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
TextButton(onClick = viewModel::logout) {
Icon(Icons.AutoMirrored.Filled.Logout, contentDescription = null)
Text("Logga ut", modifier = Modifier.padding(start = 6.dp))
}
}
}
@OptIn(androidx.compose.material3.ExperimentalMaterial3Api::class)
@Composable
private fun WeightDialog(
title: String,
initial: Double?,
initialDate: java.time.LocalDate,
initialMuscle: Double?,
initialFat: Double?,
initialWater: Double?,
onSave: (Double, String?, Double?, Double?, Double?) -> Unit,
onDismiss: () -> Unit,
onDelete: (() -> Unit)? = null,
) {
fun fmt(v: Double?) = v?.let { if (it % 1.0 == 0.0) it.toInt().toString() else it.toString() } ?: ""
var text by remember { mutableStateOf(fmt(initial)) }
var muscle by remember { mutableStateOf(fmt(initialMuscle)) }
var fat by remember { mutableStateOf(fmt(initialFat)) }
var water by remember { mutableStateOf(fmt(initialWater)) }
var date by remember { mutableStateOf(initialDate) }
var showDatePicker by remember { mutableStateOf(false) }
val parsed = text.trim().replace(',', '.').toDoubleOrNull()
fun pct(s: String) = s.trim().replace(',', '.').toDoubleOrNull()
if (showDatePicker) {
val dateState = androidx.compose.material3.rememberDatePickerState(
initialSelectedDateMillis = date.atStartOfDay(java.time.ZoneOffset.UTC)
.toInstant().toEpochMilli(),
)
androidx.compose.material3.DatePickerDialog(
onDismissRequest = { showDatePicker = false },
confirmButton = {
TextButton(onClick = {
dateState.selectedDateMillis?.let { picked ->
date = java.time.Instant.ofEpochMilli(picked)
.atZone(java.time.ZoneOffset.UTC).toLocalDate()
}
showDatePicker = false
}) { Text("OK") }
},
dismissButton = {
TextButton(onClick = { showDatePicker = false }) { Text("Avbryt") }
},
) {
androidx.compose.material3.DatePicker(state = dateState)
}
}
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(title) },
text = {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedTextField(
value = text,
onValueChange = { text = it },
label = { Text("Vikt (kg)") },
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
modifier = Modifier.fillMaxWidth(),
)
androidx.compose.material3.FilledTonalButton(
onClick = { showDatePicker = true },
modifier = Modifier.fillMaxWidth(),
) {
Text("Datum: $date")
}
Text(
"Kroppssammansättning (valfritt)",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedTextField(
value = muscle,
onValueChange = { muscle = it },
label = { Text("Muskel %") },
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
modifier = Modifier.weight(1f),
)
OutlinedTextField(
value = fat,
onValueChange = { fat = it },
label = { Text("Fett %") },
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
modifier = Modifier.weight(1f),
)
}
OutlinedTextField(
value = water,
onValueChange = { water = it },
label = { Text("Vatten %") },
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
modifier = Modifier.fillMaxWidth(),
)
onDelete?.let {
TextButton(onClick = it) {
Text("Ta bort mätningen", color = MaterialTheme.colorScheme.error)
}
}
}
},
confirmButton = {
Button(
onClick = {
parsed?.let { weight ->
// Mitt på dagen UTC så datumet inte glider en dag i någon tidszon
val iso = date.atTime(12, 0).atOffset(java.time.ZoneOffset.UTC)
.toInstant().toString()
onSave(weight, iso, pct(muscle), pct(fat), pct(water))
}
},
enabled = parsed != null && parsed > 0,
) { Text("Spara") }
},
dismissButton = {
TextButton(onClick = onDismiss) { Text("Avbryt") }
},
)
}
@Composable
private fun ProfileRow(label: String, value: String) {
Row(modifier = Modifier.fillMaxWidth()) {
Text(
label,
modifier = Modifier.weight(1f),
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(value)
}
}

View File

@@ -0,0 +1,175 @@
package eu.brassepc.fitnessdroid.ui.session
import androidx.compose.foundation.background
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.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilterChip
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
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.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
/** Standardfärger på viktskivor (IPF-ish). Vikt i gram för exakt matematik. */
private data class Plate(val grams: Int, val color: Color, val heightDp: Dp, val widthDp: Dp)
private val PLATES = listOf(
Plate(25_000, Color(0xFFD32F2F), 88.dp, 13.dp), // 25 röd
Plate(20_000, Color(0xFF1E64C8), 88.dp, 12.dp), // 20 blå
Plate(15_000, Color(0xFFF9C513), 76.dp, 11.dp), // 15 gul
Plate(10_000, Color(0xFF3E9B4F), 62.dp, 10.dp), // 10 grön
Plate(5_000, Color(0xFFECECE4), 46.dp, 9.dp), // 5 vit
Plate(2_500, Color(0xFF212121), 34.dp, 8.dp), // 2,5 svart
Plate(1_250, Color(0xFF9E9E9E), 26.dp, 7.dp), // 1,25 grå
)
private fun breakdown(perSideGrams: Int): Pair<List<Plate>, Int> {
var rest = perSideGrams
val result = mutableListOf<Plate>()
for (plate in PLATES) {
while (rest >= plate.grams) {
result += plate
rest -= plate.grams
}
}
return result to rest
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun PlateCalculatorSheet(
weightKg: Double,
customBarWeights: List<Double>,
onDismiss: () -> Unit,
) {
var barKg by remember { mutableStateOf(20.0) }
val barOptions = (listOf(20.0, 0.0) + customBarWeights).distinct()
val perSideGrams = (((weightKg - barKg) / 2.0) * 1000).toInt().coerceAtLeast(0)
val (plates, restGrams) = breakdown(perSideGrams)
ModalBottomSheet(onDismissRequest = onDismiss) {
Column(
modifier = Modifier.padding(horizontal = 20.dp).padding(bottom = 28.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Text(
"${weightKg.compact()} kg på stången",
style = MaterialTheme.typography.titleLarge,
)
androidx.compose.foundation.lazy.LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
items(barOptions.size) { i ->
val option = barOptions[i]
FilterChip(
selected = barKg == option,
onClick = { barKg = option },
label = {
Text(
when (option) {
0.0 -> "Utan stång"
20.0 -> "Stång 20 kg"
else -> "Stång ${option.compact()} kg"
}
)
},
)
}
}
if (weightKg < barKg) {
Text(
"Vikten är lägre än stången — bara stången räcker (${barKg.compact()} kg).",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
} else {
// Skivorna för ena sidan, störst innerst
Row(
modifier = Modifier
.fillMaxWidth()
.height(96.dp),
verticalAlignment = Alignment.CenterVertically,
) {
// stångskaft in mot mitten
Box(
modifier = Modifier
.weight(1f)
.height(8.dp)
.background(Color(0xFF757575), RoundedCornerShape(3.dp)),
)
// låskrage
Box(
modifier = Modifier
.padding(horizontal = 2.dp)
.size(width = 8.dp, height = 24.dp)
.background(Color(0xFF8D8D8D), RoundedCornerShape(2.dp)),
)
plates.forEach { plate ->
Box(
modifier = Modifier
.padding(horizontal = 1.5.dp)
.size(width = plate.widthDp, height = plate.heightDp)
.background(plate.color, RoundedCornerShape(3.dp)),
)
}
Box(
modifier = Modifier
.weight(0.3f)
.height(8.dp)
.background(Color(0xFF757575), RoundedCornerShape(3.dp)),
)
}
Text(
buildString {
append("Per sida: ")
if (plates.isEmpty()) {
append("inga skivor")
} else {
append(
plates.groupBy { it.grams }.entries
.sortedByDescending { it.key }
.joinToString(" + ") { (grams, list) ->
val kg = grams / 1000.0
if (list.size > 1) "${list.size}×${kg.compact()}" else kg.compact()
}
)
append(" kg")
}
},
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
)
if (restGrams > 0) {
val reachable = weightKg - (restGrams * 2 / 1000.0)
Text(
"Går inte att lägga exakt med standardskivor — närmast är " +
"${reachable.compact()} kg (${(restGrams / 1000.0).compact()} kg/sida saknas).",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,245 @@
package eu.brassepc.fitnessdroid.ui.session
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory
import eu.brassepc.fitnessdroid.data.AppSettings
import eu.brassepc.fitnessdroid.data.GymRepository
import eu.brassepc.fitnessdroid.data.RestState
import eu.brassepc.fitnessdroid.data.RestTimerController
import eu.brassepc.fitnessdroid.data.SettingsStore
import eu.brassepc.fitnessdroid.data.local.CachedExerciseType
import eu.brassepc.fitnessdroid.data.local.LocalSet
import eu.brassepc.fitnessdroid.ui.appContainer
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
/** Utkast till nästa set för en övning (styrs av steppers i UI:t). */
data class SetDraft(
val weight: Double? = null,
val reps: Int? = null,
val durationSeconds: Int? = null,
val distanceMeters: Double? = null,
val rpe: Double? = null,
)
data class ExercisePage(
val localId: Long,
val type: CachedExerciseType?,
val name: String,
val doneSets: List<LocalSet>,
val draft: SetDraft,
val lastPerformanceText: String?,
)
data class SessionUiState(
val sessionId: Long? = null,
val sessionName: String,
val startedAtEpochMs: Long = 0,
val pages: List<ExercisePage> = emptyList(),
val pendingCount: Int = 0,
val rest: RestState? = null,
val settings: AppSettings = AppSettings(),
val completedLocally: Boolean = false,
) {
companion object { val EMPTY = SessionUiState(sessionName = "Pass") }
}
@OptIn(ExperimentalCoroutinesApi::class)
class SessionViewModel(
private val repo: GymRepository,
private val restTimer: RestTimerController,
private val settingsStore: SettingsStore,
) : ViewModel() {
/** exerciseLocalId → utkast som användaren pillat på */
private val drafts = MutableStateFlow<Map<Long, SetDraft>>(emptyMap())
private val sessionFlow = repo.activeSession
private val bundleFlow = sessionFlow.flatMapLatest { session ->
if (session == null) {
flowOf(SessionUiState.EMPTY)
} else {
combine(
repo.exercises(session.id),
repo.setsForSession(session.id),
repo.exerciseTypes,
drafts,
repo.pendingCount,
) { exercises, sets, types, draftMap, pending ->
val typeById = types.associateBy { it.id }
val setsByExercise = sets.groupBy { it.exerciseId }
SessionUiState(
sessionId = session.id,
sessionName = session.name ?: "Fritt pass",
startedAtEpochMs = session.startedAtEpochMs,
pendingCount = pending,
pages = exercises.map { ex ->
val type = typeById[ex.exerciseTypeId]
val done = setsByExercise[ex.id].orEmpty()
ExercisePage(
localId = ex.id,
type = type,
name = type?.name ?: "Övning ${ex.order}",
doneSets = done,
draft = draftMap[ex.id] ?: initialDraft(type, done),
lastPerformanceText = type?.lastWeight?.let { w ->
"Senast: ${w.compact()} kg" +
(type.lastReps?.let { " × $it" } ?: "")
},
)
},
)
}
}
}
val uiState = combine(
bundleFlow,
restTimer.state,
settingsStore.settings,
) { bundle, rest, settings ->
bundle.copy(rest = rest, settings = settings)
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), SessionUiState.EMPTY)
/** Förifyll utkastet: senaste setet i passet, annars cachens senaste prestation. */
private fun initialDraft(type: CachedExerciseType?, done: List<LocalSet>): SetDraft {
val last = done.lastOrNull()
return SetDraft(
weight = last?.weight ?: type?.lastWeight
?: if (type?.tracksWeight == true) 20.0 else null,
reps = last?.reps ?: type?.lastReps
?: if (type?.tracksReps == true) 8 else null,
durationSeconds = last?.durationSeconds ?: type?.lastDurationSeconds
?: if (type?.tracksDuration == true) 30 else null,
distanceMeters = last?.distanceMeters ?: type?.lastDistanceMeters
?: if (type?.tracksDistance == true) 1000.0 else null,
rpe = last?.rpe,
)
}
fun updateDraft(exerciseId: Long, transform: (SetDraft) -> SetDraft) {
val page = uiState.value.pages.firstOrNull { it.localId == exerciseId } ?: return
drafts.value = drafts.value + (exerciseId to transform(page.draft))
}
fun logSet(exerciseId: Long) {
val page = uiState.value.pages.firstOrNull { it.localId == exerciseId } ?: return
val d = page.draft
viewModelScope.launch {
repo.logSet(
exerciseId = exerciseId,
reps = if (page.type?.tracksReps != false) d.reps else null,
weight = if (page.type?.tracksWeight != false) d.weight else null,
distanceMeters = if (page.type?.tracksDistance == true) d.distanceMeters else null,
durationSeconds = if (page.type?.tracksDuration == true) d.durationSeconds else null,
rpe = d.rpe,
isWarmup = false,
)
// API-tillägget defaultRestSeconds per övning; annars appens standard.
restTimer.start(page.type?.defaultRestSeconds)
}
}
fun adjustRest(delta: Int) = restTimer.adjust(delta)
fun skipRest() = restTimer.dismiss()
/** Rätta ett redan loggat set (synkas som update, eller bärs av väntande add). */
fun saveEditedSet(setId: Long, draft: SetDraft) {
val set = uiState.value.pages.flatMap { it.doneSets }.firstOrNull { it.id == setId } ?: return
viewModelScope.launch {
repo.updateSet(
set.copy(
weight = draft.weight,
reps = draft.reps,
durationSeconds = draft.durationSeconds,
distanceMeters = draft.distanceMeters,
rpe = draft.rpe,
)
)
}
}
fun deleteSet(setId: Long) {
viewModelScope.launch { repo.deleteSet(setId) }
}
/* ---------- Övningshistorik-arket ---------- */
sealed interface HistorySheetState {
data class Loading(val exerciseName: String) : HistorySheetState
data class Error(val exerciseName: String) : HistorySheetState
data class Ready(
val exerciseName: String,
val history: eu.brassepc.fitnessdroid.data.ExerciseHistory,
) : HistorySheetState
}
private val _historySheet = MutableStateFlow<HistorySheetState?>(null)
val historySheet = _historySheet.asStateFlow()
fun openExerciseHistory(page: ExercisePage) {
val typeId = page.type?.id ?: return
_historySheet.value = HistorySheetState.Loading(page.name)
viewModelScope.launch {
_historySheet.value = try {
HistorySheetState.Ready(page.name, repo.fetchExerciseHistory(typeId))
} catch (e: Exception) {
HistorySheetState.Error(page.name)
}
}
}
fun closeExerciseHistory() {
_historySheet.value = null
}
/** Kroppsvikt för kaloriuppskattningen (cachad från profilen). */
val bodyWeightKg = MutableStateFlow<Double?>(null)
/** Personbästa (typeId, reps) → vikt, för 🏆-markeringen vid avslut. */
val personalBests = MutableStateFlow<Map<Pair<Int, Int>, Double>>(emptyMap())
init {
viewModelScope.launch {
bodyWeightKg.value = repo.bodyWeightKg()
personalBests.value = repo.personalBests()
}
}
fun completeSession(
durationSecondsOverride: Int?,
editedStartEpochMs: Long?,
onDone: () -> Unit,
) {
viewModelScope.launch {
val id = uiState.value.sessionId ?: sessionFlow.first()?.id ?: return@launch
restTimer.dismiss()
repo.completeSession(id, durationSecondsOverride, editedStartEpochMs)
onDone()
}
}
companion object {
val Factory: ViewModelProvider.Factory = viewModelFactory {
initializer {
val c = appContainer()
SessionViewModel(c.gymRepository, c.restTimer, c.settingsStore)
}
}
}
}
internal fun Double.compact(): String =
if (this % 1.0 == 0.0) toInt().toString() else "%.1f".format(this)

View File

@@ -0,0 +1,351 @@
package eu.brassepc.fitnessdroid.ui.settings
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.selection.selectable
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Close
import androidx.compose.runtime.setValue
import androidx.compose.material3.Card
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory
import eu.brassepc.fitnessdroid.data.AppSettings
import eu.brassepc.fitnessdroid.data.RestAlert
import eu.brassepc.fitnessdroid.data.RestTimerStyle
import eu.brassepc.fitnessdroid.data.SettingsStore
import eu.brassepc.fitnessdroid.ui.appContainer
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
class SettingsViewModel(
private val store: SettingsStore,
private val tokenStore: eu.brassepc.fitnessdroid.data.TokenStore,
private val credentialStore: eu.brassepc.fitnessdroid.data.CredentialStore,
) : ViewModel() {
val settings = store.settings
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), AppSettings())
val apiUrl = kotlinx.coroutines.flow.MutableStateFlow("")
init {
viewModelScope.launch { apiUrl.value = tokenStore.apiUrl() }
}
fun setStyle(value: RestTimerStyle) = viewModelScope.launch { store.setRestTimerStyle(value) }
fun setAlert(value: RestAlert) = viewModelScope.launch { store.setRestAlert(value) }
fun adjustRestSeconds(delta: Int) = viewModelScope.launch {
store.setDefaultRestSeconds(settings.value.defaultRestSeconds + delta)
}
fun setWeightStep(value: Double) = viewModelScope.launch { store.setWeightStep(value) }
fun addCustomBar(value: Double) = viewModelScope.launch { store.addCustomBarWeight(value) }
fun removeCustomBar(value: Double) = viewModelScope.launch { store.removeCustomBarWeight(value) }
fun setAutoRelogin(value: Boolean) = viewModelScope.launch {
store.setAutoRelogin(value)
// Stängs funktionen av slängs de sparade uppgifterna direkt.
if (!value) runCatching { credentialStore.clear() }
}
fun onApiUrlChange(value: String) { apiUrl.value = value }
fun saveApiUrl() = viewModelScope.launch {
tokenStore.saveApiUrl(apiUrl.value.trim())
}
companion object {
val Factory: ViewModelProvider.Factory = viewModelFactory {
initializer {
val c = appContainer()
SettingsViewModel(c.settingsStore, c.tokenStore, c.credentialStore)
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SettingsScreen(
onBack: () -> Unit,
viewModel: SettingsViewModel = viewModel(factory = SettingsViewModel.Factory),
) {
val settings by viewModel.settings.collectAsStateWithLifecycle()
Scaffold(
topBar = {
TopAppBar(
title = { Text("Inställningar") },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Tillbaka")
}
},
)
},
) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.verticalScroll(rememberScrollState())
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
SettingsCard(title = "Vilotimerns utseende") {
RadioRow(
label = "Helskärm",
description = "Timern tar över skärmen mellan seten",
selected = settings.restTimerStyle == RestTimerStyle.FULLSCREEN,
onClick = { viewModel.setStyle(RestTimerStyle.FULLSCREEN) },
)
RadioRow(
label = "Liten banner",
description = "Diskret rad längst ner på pass-sidan",
selected = settings.restTimerStyle == RestTimerStyle.BANNER,
onClick = { viewModel.setStyle(RestTimerStyle.BANNER) },
)
}
SettingsCard(title = "När vilan är klar") {
RadioRow(
label = "Larmljud",
description = "Spelar notissignalen",
selected = settings.restAlert == RestAlert.SOUND,
onClick = { viewModel.setAlert(RestAlert.SOUND) },
)
RadioRow(
label = "Vibration",
description = "Telefonen vibrerar",
selected = settings.restAlert == RestAlert.VIBRATE,
onClick = { viewModel.setAlert(RestAlert.VIBRATE) },
)
RadioRow(
label = "Bara visuellt",
description = "Timern byter utseende, inget ljud eller surr",
selected = settings.restAlert == RestAlert.VISUAL,
onClick = { viewModel.setAlert(RestAlert.VISUAL) },
)
RadioRow(
label = "Ingenting",
description = "Timern försvinner tyst",
selected = settings.restAlert == RestAlert.NONE,
onClick = { viewModel.setAlert(RestAlert.NONE) },
)
}
SettingsCard(title = "Viktsteg för + / i passläget") {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
listOf(1.25, 2.5, 5.0).forEach { step ->
androidx.compose.material3.FilterChip(
selected = settings.weightStep == step,
onClick = { viewModel.setWeightStep(step) },
label = {
Text(if (step % 1.0 == 0.0) "${step.toInt()} kg" else "$step kg")
},
modifier = Modifier.weight(1f),
)
}
}
}
SettingsCard(title = "Skivkalkylatorn — egna stänger") {
androidx.compose.foundation.lazy.LazyRow(
horizontalArrangement = Arrangement.spacedBy(8.dp),
contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 16.dp),
) {
items(settings.customBarWeights.size) { i ->
val bar = settings.customBarWeights[i]
androidx.compose.material3.InputChip(
selected = false,
onClick = { viewModel.removeCustomBar(bar) },
label = {
Text(
if (bar % 1.0 == 0.0) "${bar.toInt()} kg" else "$bar kg".replace('.', ',')
)
},
trailingIcon = {
androidx.compose.material3.Icon(
Icons.Default.Close,
contentDescription = "Ta bort",
)
},
)
}
}
var newBar by androidx.compose.runtime.remember {
androidx.compose.runtime.mutableStateOf("")
}
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
androidx.compose.material3.OutlinedTextField(
value = newBar,
onValueChange = { newBar = it },
label = { Text("Ny stångvikt (t.ex. 13,7)") },
singleLine = true,
keyboardOptions = androidx.compose.foundation.text.KeyboardOptions(
keyboardType = androidx.compose.ui.text.input.KeyboardType.Decimal,
),
modifier = Modifier.weight(1f),
)
FilledTonalButton(onClick = {
newBar.trim().replace(',', '.').toDoubleOrNull()?.let {
viewModel.addCustomBar(it)
newBar = ""
}
}) { Text("Lägg till") }
}
Text(
"Snabbvalen 20 kg och utan stång finns alltid i kalkylatorn.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(start = 16.dp, end = 16.dp, bottom = 12.dp),
)
}
SettingsCard(title = "Inloggning") {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Column(modifier = Modifier.weight(1f)) {
Text("Automatisk återinloggning", style = MaterialTheme.typography.bodyLarge)
Text(
"Sparar inloggningen krypterat (Android Keystore) och loggar " +
"in igen tyst om servern kastat ut dig. " +
"Aktiveras vid nästa inloggning.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
androidx.compose.material3.Switch(
checked = settings.autoRelogin,
onCheckedChange = viewModel::setAutoRelogin,
)
}
val apiUrl by viewModel.apiUrl.collectAsStateWithLifecycle()
androidx.compose.material3.OutlinedTextField(
value = apiUrl,
onValueChange = viewModel::onApiUrlChange,
label = { Text("API-url") },
singleLine = true,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 6.dp),
trailingIcon = {
androidx.compose.material3.TextButton(onClick = viewModel::saveApiUrl) {
Text("Spara")
}
},
)
Text(
"Gäller från nästa anrop. Standard: https://gymapi.brasse-pc.eu/graphql",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(start = 16.dp, end = 16.dp, bottom = 12.dp),
)
}
SettingsCard(title = "Standardvila mellan set") {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
FilledTonalButton(onClick = { viewModel.adjustRestSeconds(-15) }) { Text("15 s") }
Text(
"${settings.defaultRestSeconds} s",
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold,
modifier = Modifier.weight(1f),
textAlign = androidx.compose.ui.text.style.TextAlign.Center,
)
FilledTonalButton(onClick = { viewModel.adjustRestSeconds(15) }) { Text("+15 s") }
}
Text(
"Övningar kan få egen vilotid via API:t — då gäller den istället.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(start = 16.dp, end = 16.dp, bottom = 12.dp),
)
}
}
}
}
@Composable
private fun SettingsCard(title: String, content: @Composable () -> Unit) {
Card(modifier = Modifier.fillMaxWidth()) {
Column {
Text(
title,
style = MaterialTheme.typography.titleSmall,
modifier = Modifier.padding(start = 16.dp, top = 14.dp, bottom = 4.dp),
)
content()
}
}
}
@Composable
private fun RadioRow(label: String, description: String, selected: Boolean, onClick: () -> Unit) {
Row(
modifier = Modifier
.fillMaxWidth()
.selectable(selected = selected, onClick = onClick)
.padding(horizontal = 16.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically,
) {
RadioButton(selected = selected, onClick = onClick)
Column(modifier = Modifier.padding(start = 8.dp)) {
Text(label, style = MaterialTheme.typography.bodyLarge)
Text(
description,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}

View File

@@ -0,0 +1,133 @@
package eu.brassepc.fitnessdroid.ui.stats
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.size
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.lerp
import androidx.compose.ui.unit.dp
/**
* Stiliserad kroppskarta (fram + bak) där muskelgruppernas volym färgas som
* värme. Samma formspråk som designförslaget: byggd av enkla former, tydlig
* i litet format, temafärgad.
*/
/** Zon-nycklar på figuren. En muskelgrupp kan värma flera zoner. */
private enum class Zone { SHOULDERS, CHEST, ARMS, FOREARMS, CORE, QUADS, CALVES, TRAPS, BACK, LOWERBACK, GLUTES, HAMSTRINGS }
/** Muskelgruppnamn (svenska/engelska heuristik) → zoner. */
private fun zonesFor(groupName: String): List<Zone> {
val n = groupName.lowercase()
return when {
"bröst" in n || "chest" in n -> listOf(Zone.CHEST)
"axl" in n || "shoulder" in n || "delt" in n -> listOf(Zone.SHOULDERS, Zone.TRAPS)
"arm" in n || "bicep" in n || "tricep" in n -> listOf(Zone.ARMS, Zone.FOREARMS)
"rygg" in n || "back" in n || "lat" in n -> listOf(Zone.BACK, Zone.LOWERBACK)
"mage" in n || "core" in n || "abs" in n || "bål" in n -> listOf(Zone.CORE)
"vad" in n || "calv" in n -> listOf(Zone.CALVES)
"ben" in n || "leg" in n || "quad" in n || "glut" in n || "hamstring" in n ->
listOf(Zone.QUADS, Zone.GLUTES, Zone.HAMSTRINGS)
else -> emptyList()
}
}
@Composable
fun BodyHeatMap(
muscleVolumes: Map<String, Double>,
baseColor: Color,
hotColor: Color,
modifier: Modifier = Modifier,
) {
val zoneHeat = HashMap<Zone, Double>()
muscleVolumes.forEach { (group, volume) ->
zonesFor(group).forEach { zone ->
zoneHeat[zone] = (zoneHeat[zone] ?: 0.0) + volume
}
}
val max = zoneHeat.values.maxOrNull()?.takeIf { it > 0 } ?: 1.0
fun heat(zone: Zone): Color =
lerp(baseColor, hotColor, ((zoneHeat[zone] ?: 0.0) / max).toFloat().coerceIn(0f, 1f))
Row(modifier = modifier) {
Canvas(modifier = Modifier.size(120.dp, 234.dp)) {
drawFigureFront(::heat)
}
Canvas(modifier = Modifier.size(120.dp, 234.dp)) {
drawFigureBack(::heat)
}
}
}
/* Koordinatsystem 100×195 (som designförslaget), skalas till canvasen. */
private fun DrawScope.px(x: Double, y: Double, w: Double, h: Double, r: Double, color: Color) {
val sx = size.width / 100f
val sy = size.height / 195f
drawRoundRect(
color = color,
topLeft = Offset((x * sx).toFloat(), (y * sy).toFloat()),
size = Size((w * sx).toFloat(), (h * sy).toFloat()),
cornerRadius = CornerRadius((r * sx).toFloat()),
)
}
private fun DrawScope.head(neutral: Color) {
val sx = size.width / 100f
val sy = size.height / 195f
drawCircle(neutral, radius = 9f * sx, center = Offset(50f * sx, 12f * sy))
}
private fun DrawScope.drawFigureFront(heat: (Zone) -> Color) {
val neutral = heat(Zone.CORE).copy(alpha = 0.25f)
head(neutral)
px(43.0, 22.0, 14.0, 7.0, 3.0, neutral) // hals
// axlar
px(26.0, 30.0, 15.0, 12.0, 6.0, heat(Zone.SHOULDERS))
px(59.0, 30.0, 15.0, 12.0, 6.0, heat(Zone.SHOULDERS))
// bröst
px(33.0, 34.0, 15.0, 14.0, 6.0, heat(Zone.CHEST))
px(52.0, 34.0, 15.0, 14.0, 6.0, heat(Zone.CHEST))
// mage/core
px(36.0, 50.0, 28.0, 26.0, 8.0, heat(Zone.CORE))
// överarmar + underarmar
px(22.0, 40.0, 8.0, 24.0, 4.0, heat(Zone.ARMS))
px(70.0, 40.0, 8.0, 24.0, 4.0, heat(Zone.ARMS))
px(21.0, 66.0, 7.0, 20.0, 3.5, heat(Zone.FOREARMS))
px(72.0, 66.0, 7.0, 20.0, 3.5, heat(Zone.FOREARMS))
// lår
px(34.0, 80.0, 13.0, 42.0, 6.0, heat(Zone.QUADS))
px(53.0, 80.0, 13.0, 42.0, 6.0, heat(Zone.QUADS))
// smalben (neutralt fram)
px(36.0, 126.0, 10.0, 30.0, 5.0, neutral)
px(54.0, 126.0, 10.0, 30.0, 5.0, neutral)
}
private fun DrawScope.drawFigureBack(heat: (Zone) -> Color) {
val neutral = heat(Zone.CORE).copy(alpha = 0.25f)
head(neutral)
// trapezius/nacke
px(40.0, 22.0, 20.0, 10.0, 4.0, heat(Zone.TRAPS))
// rygg (lats)
px(34.0, 33.0, 32.0, 22.0, 8.0, heat(Zone.BACK))
// ländrygg
px(38.0, 57.0, 24.0, 18.0, 7.0, heat(Zone.LOWERBACK))
// armar bak
px(22.0, 40.0, 8.0, 24.0, 4.0, heat(Zone.ARMS))
px(70.0, 40.0, 8.0, 24.0, 4.0, heat(Zone.ARMS))
px(21.0, 66.0, 7.0, 20.0, 3.5, heat(Zone.FOREARMS))
px(72.0, 66.0, 7.0, 20.0, 3.5, heat(Zone.FOREARMS))
// säte
px(35.0, 77.0, 30.0, 14.0, 6.0, heat(Zone.GLUTES))
// baksida lår
px(34.0, 93.0, 13.0, 32.0, 6.0, heat(Zone.HAMSTRINGS))
px(53.0, 93.0, 13.0, 32.0, 6.0, heat(Zone.HAMSTRINGS))
// vader
px(36.0, 128.0, 10.0, 28.0, 5.0, heat(Zone.CALVES))
px(54.0, 128.0, 10.0, 28.0, 5.0, heat(Zone.CALVES))
}

View File

@@ -0,0 +1,57 @@
package eu.brassepc.fitnessdroid.ui.stats
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.unit.dp
import eu.brassepc.fitnessdroid.data.BodyMeasurement
import java.time.OffsetDateTime
data class CompositionSeries(val label: String, val color: Color, val values: List<Double?>)
/**
* Flerlinjes-graf för kroppssammansättning över tid. Varje serie (muskel/
* fett/vatten) ritas som egen linje; null-värden hoppas över (spanGaps).
* Delar tidsordningen med [WeightGraph].
*/
@Composable
fun CompositionGraph(
measurements: List<BodyMeasurement>,
series: List<CompositionSeries>,
modifier: Modifier = Modifier,
) {
val order = measurements.mapIndexedNotNull { i, m ->
runCatching { OffsetDateTime.parse(m.date).toInstant().toEpochMilli() }.getOrNull()?.let { i to it }
}.sortedBy { it.second }.map { it.first }
if (order.size < 2) return
val allValues = series.flatMap { it.values }.filterNotNull()
if (allValues.isEmpty()) return
val lo = allValues.min() * 0.9
val hi = allValues.max() * 1.1
val span = (hi - lo).takeIf { it > 0 } ?: 1.0
Canvas(modifier = modifier.fillMaxWidth().height(160.dp)) {
fun x(idx: Int) = idx / (order.size - 1).toFloat() * size.width
fun y(v: Double) = (size.height - ((v - lo) / span).toFloat() * size.height)
series.forEach { s ->
val path = Path()
var started = false
order.forEachIndexed { plotIdx, measurementIdx ->
val v = s.values.getOrNull(measurementIdx) ?: return@forEachIndexed
val px = x(plotIdx)
val py = y(v)
if (!started) { path.moveTo(px, py); started = true } else path.lineTo(px, py)
drawCircle(s.color, radius = 5f, center = Offset(px, py))
}
if (started) drawPath(path, color = s.color, style = Stroke(width = 4f))
}
}
}

View File

@@ -0,0 +1,513 @@
package eu.brassepc.fitnessdroid.ui.stats
import androidx.compose.foundation.background
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.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.FilterChip
import androidx.compose.material3.MaterialTheme
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.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 androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.compose.viewModel
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.ui.appContainer
import eu.brassepc.fitnessdroid.ui.common.compact
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.launch
data class PeriodOption(val id: String, val label: String)
val PERIODS = listOf(
PeriodOption("week", "Vecka"),
PeriodOption("month", "Månad"),
PeriodOption("halfyear", "Halvår"),
PeriodOption("year", "År"),
PeriodOption("all", "Totalt"),
)
sealed interface StatsUiState {
data object Loading : StatsUiState
data class Error(val message: String) : StatsUiState
data class Ready(val stats: StatsBundle) : StatsUiState
}
class StatsViewModel(private val repo: GymRepository) : ViewModel() {
val period = MutableStateFlow("month")
val uiState = MutableStateFlow<StatsUiState>(StatsUiState.Loading)
val bodyMeasurements = MutableStateFlow<List<eu.brassepc.fitnessdroid.data.BodyMeasurement>>(emptyList())
init {
load()
refreshMeasurements()
}
fun refreshMeasurements() {
viewModelScope.launch {
runCatching { bodyMeasurements.value = repo.fetchBodyMeasurements() }
}
}
fun setPeriod(id: String) {
if (period.value == id) return
period.value = id
load()
}
fun load() {
uiState.value = StatsUiState.Loading
viewModelScope.launch {
uiState.value = try {
StatsUiState.Ready(repo.fetchStats(period.value))
} catch (e: Exception) {
StatsUiState.Error("Kunde inte hämta statistiken — offline?")
}
}
}
companion object {
val Factory: ViewModelProvider.Factory = viewModelFactory {
initializer { StatsViewModel(appContainer().gymRepository) }
}
}
}
@Composable
fun StatsScreen(viewModel: StatsViewModel = viewModel(factory = StatsViewModel.Factory)) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val period by viewModel.period.collectAsStateWithLifecycle()
// Hämta om mätningarna varje gång fliken öppnas — ny vikt kan ha
// lagts in via profilen sedan sist.
androidx.compose.runtime.LaunchedEffect(Unit) {
viewModel.refreshMeasurements()
}
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = androidx.compose.foundation.layout.PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
item {
Text("Statistik", style = MaterialTheme.typography.headlineSmall)
}
item {
androidx.compose.foundation.lazy.LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
items(PERIODS, key = { it.id }) { p ->
FilterChip(
selected = period == p.id,
onClick = { viewModel.setPeriod(p.id) },
label = { Text(p.label) },
)
}
}
}
when (val state = uiState) {
is StatsUiState.Loading -> item {
Box(
modifier = Modifier.fillMaxWidth().padding(48.dp),
contentAlignment = Alignment.Center,
) { CircularProgressIndicator() }
}
is StatsUiState.Error -> item {
Column(
modifier = Modifier.fillMaxWidth().padding(32.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(state.message, color = MaterialTheme.colorScheme.onSurfaceVariant)
Button(onClick = viewModel::load, modifier = Modifier.padding(top = 12.dp)) {
Text("Försök igen")
}
}
}
is StatsUiState.Ready -> {
val stats = state.stats
item {
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
KpiCard("Pass", "${stats.sessionCount}", delta(stats.sessionCount.toDouble(), stats.prevSessionCount?.toDouble()), Modifier.weight(1f))
KpiCard("Tid", "${stats.totalDurationMinutes / 60}h ${stats.totalDurationMinutes % 60}m", delta(stats.totalDurationMinutes.toDouble(), stats.prevDurationMinutes?.toDouble()), Modifier.weight(1f))
}
}
item {
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
KpiCard("Volym", "${stats.totalVolumeKg.toInt()} kg", delta(stats.totalVolumeKg, stats.prevVolumeKg), Modifier.weight(1f))
KpiCard("Kalorier", "${stats.totalCalories.toInt()} kcal", delta(stats.totalCalories, stats.prevCalories), Modifier.weight(1f))
}
}
item {
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
KpiCard("Streak", "${stats.streakWeeks} v", null, Modifier.weight(1f))
KpiCard("Pass/vecka", "%.1f".format(stats.sessionsPerWeek), null, Modifier.weight(1f))
}
}
if (stats.trend.size > 1) {
item {
Card(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.padding(14.dp)) {
Text(
"Volym per " + when (period) {
"week", "month" -> "dag"
"halfyear" -> "vecka"
else -> "månad"
},
style = MaterialTheme.typography.titleSmall,
)
TrendBars(stats)
}
}
}
}
item {
Card(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.padding(14.dp)) {
Text("Var hamnade volymen?", style = MaterialTheme.typography.titleSmall)
if (stats.muscleVolumes.isEmpty()) {
Text(
"Ingen loggad volym i perioden.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 6.dp),
)
} else {
Row(
modifier = Modifier.fillMaxWidth().padding(top = 10.dp),
horizontalArrangement = Arrangement.SpaceEvenly,
) {
BodyHeatMap(
muscleVolumes = stats.muscleVolumes,
baseColor = MaterialTheme.colorScheme.surfaceVariant,
hotColor = MaterialTheme.colorScheme.primary,
)
}
Row(
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text("Framsida", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
Text("Mörkare = mer volym", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
Text("Baksida", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
stats.muscleVolumes.entries.sortedByDescending { it.value }.take(3)
.let { top ->
Text(
"Mest: " + top.joinToString(" · ") { "${it.key} ${it.value.toInt()} kg" },
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 8.dp),
)
}
}
}
}
}
item {
val measurements by viewModel.bodyMeasurements.collectAsStateWithLifecycle()
if (measurements.size >= 2) {
Card(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.padding(14.dp)) {
Row {
Text(
"Kroppsvikt",
style = MaterialTheme.typography.titleSmall,
modifier = Modifier.weight(1f),
)
Text(
"${measurements.last().weightKg.compact()} kg nu",
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.primary,
)
}
WeightGraph(
measurements = measurements,
lineColor = MaterialTheme.colorScheme.primary,
trendColor = MaterialTheme.colorScheme.tertiary,
pointColor = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 10.dp),
)
Row(
modifier = Modifier.fillMaxWidth().padding(top = 6.dp),
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(
measurements.first().date.take(10),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
"— mjuk linje · - - trend",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
measurements.last().date.take(10),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
}
item {
val measurements by viewModel.bodyMeasurements.collectAsStateWithLifecycle()
val hasComposition = measurements.any {
it.musclePercent != null || it.fatPercent != null || it.waterPercent != null
}
if (measurements.size >= 2 && hasComposition) {
CompositionCard(measurements)
}
}
if (stats.heaviestLift != null || stats.newPrCount > 0) {
item {
Card(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text("Höjdpunkter", style = MaterialTheme.typography.titleSmall)
stats.heaviestLift?.let { Text("🏋 Tyngsta lyft: $it", style = MaterialTheme.typography.bodySmall) }
if (stats.newPrCount > 0) {
Text("🏆 ${stats.newPrCount} nya personbästa i perioden", style = MaterialTheme.typography.bodySmall)
}
}
}
}
}
if (stats.personalBests.isNotEmpty()) {
item {
Text(
"Personbästa",
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.padding(top = 6.dp),
)
}
items(stats.personalBests, key = { it.exerciseName }) { pb ->
Card(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.padding(14.dp)) {
Text(pb.exerciseName, style = MaterialTheme.typography.titleSmall)
Text(
pb.records.joinToString(" ") { "${it.reps}RM ${it.weight.compact()}kg" },
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 4.dp),
)
}
}
}
}
}
}
}
}
private data class CompPart(val label: String, val value: (eu.brassepc.fitnessdroid.data.BodyMeasurement) -> Double?)
@Composable
private fun CompositionCard(measurements: List<eu.brassepc.fitnessdroid.data.BodyMeasurement>) {
var showKg by remember { mutableStateOf(false) }
val latest = measurements.last()
val muscle = MaterialTheme.colorScheme.primary
val fat = MaterialTheme.colorScheme.tertiary
val water = MaterialTheme.colorScheme.secondary
val parts = listOf(
Triple("Muskler", muscle, CompPart("Muskler") { it.musclePercent }),
Triple("Fett", fat, CompPart("Fett") { it.fatPercent }),
Triple("Vatten", water, CompPart("Vatten") { it.waterPercent }),
).filter { (_, _, p) -> measurements.any { p.value(it) != null } }
fun kgOf(m: eu.brassepc.fitnessdroid.data.BodyMeasurement, pct: Double?): Double? =
if (pct == null) null else m.weightKg * pct / 100.0
Card(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.padding(14.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
"Sammansättning",
style = MaterialTheme.typography.titleSmall,
modifier = Modifier.weight(1f),
)
// %/kg-växel
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
FilterChip(
selected = !showKg,
onClick = { showKg = false },
label = { Text("%") },
)
FilterChip(
selected = showKg,
onClick = { showKg = true },
label = { Text("kg") },
)
}
}
// Senaste värden: % + omräknat till kg
Row(
modifier = Modifier.fillMaxWidth().padding(top = 10.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
parts.forEach { (label, color, part) ->
val pct = part.value(latest)
val kg = kgOf(latest, pct)
Column(modifier = Modifier.weight(1f)) {
Text(label, style = MaterialTheme.typography.labelSmall, color = color)
Text(
pct?.let { "${it.compact()}%" } ?: "",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
)
Text(
kg?.let { "${it.compact()} kg" } ?: "",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
CompositionGraph(
measurements = measurements,
series = parts.map { (label, color, part) ->
CompositionSeries(
label = label,
color = color,
values = measurements.map { m ->
val pct = part.value(m)
if (showKg) kgOf(m, pct) else pct
},
)
},
modifier = Modifier.padding(top = 12.dp),
)
Text(
if (showKg) "Faktisk vikt per del (kg)" else "Andel av kroppsvikten (%)",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 6.dp),
)
}
}
}
private fun delta(current: Double, previous: Double?): String? {
previous ?: return null
if (previous == 0.0) return null
val pct = ((current - previous) / previous * 100).toInt()
return if (pct >= 0) "+$pct%" else "$pct%"
}
@Composable
private fun KpiCard(label: String, value: String, deltaText: String?, modifier: Modifier = Modifier) {
Card(modifier = modifier) {
Column(modifier = Modifier.padding(12.dp)) {
Text(
label,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(value, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold)
deltaText?.let {
Text(
"$it mot förra perioden",
style = MaterialTheme.typography.labelSmall,
color = if (it.startsWith("+")) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.onSurfaceVariant
},
)
}
}
}
}
@Composable
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.
androidx.compose.runtime.LaunchedEffect(stats.trend.size) {
if (stats.trend.isNotEmpty()) listState.scrollToItem(stats.trend.size - 1)
}
androidx.compose.foundation.lazy.LazyRow(
state = listState,
modifier = Modifier
.fillMaxWidth()
.height(112.dp)
.padding(top = 10.dp),
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalAlignment = Alignment.Bottom,
) {
items(stats.trend.size) { i ->
val bucket = stats.trend[i]
Column(
modifier = Modifier.width(38.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
if (bucket.volumeKg > 0) {
Text(
if (bucket.volumeKg >= 1000) {
"${(bucket.volumeKg / 1000).toInt()}t"
} else {
"${bucket.volumeKg.toInt()}"
},
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
)
}
Box(
modifier = Modifier
.fillMaxWidth()
.height(((bucket.volumeKg / max) * 64).dp.coerceAtLeast(2.dp))
.background(
color = MaterialTheme.colorScheme.primary.copy(
alpha = if (bucket.volumeKg > 0) 0.85f else 0.25f,
),
shape = MaterialTheme.shapes.extraSmall,
),
)
Text(
bucket.label,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
)
}
}
}
}

View File

@@ -0,0 +1,93 @@
package eu.brassepc.fitnessdroid.ui.stats
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.PathEffect
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.unit.dp
import eu.brassepc.fitnessdroid.data.BodyMeasurement
import java.time.OffsetDateTime
/**
* Kroppsvikt över tid: råpunkter, glidande medelvärde (mjuk linje) och
* linjär trendlinje (streckad).
*/
@Composable
fun WeightGraph(
measurements: List<BodyMeasurement>,
lineColor: Color,
trendColor: Color,
pointColor: Color,
modifier: Modifier = Modifier,
) {
var points = measurements.mapNotNull { m ->
runCatching {
OffsetDateTime.parse(m.date).toInstant().toEpochMilli().toFloat() to m.weightKg.toFloat()
}.getOrNull()
}.sortedBy { it.first }
if (points.size < 2) return
// Alla mätningar inom samma dygn? Sprid dem jämnt på index istället,
// annars kollapsar hela grafen till en punkt.
if (points.last().first - points.first().first < 86_400_000f) {
points = points.mapIndexed { i, (_, w) -> i.toFloat() to w }
}
val minX = points.first().first
val maxX = points.last().first
val minW = points.minOf { it.second }
val maxW = points.maxOf { it.second }
val padW = ((maxW - minW).takeIf { it > 0f } ?: 1f) * 0.15f
val loW = minW - padW
val hiW = maxW + padW
// Glidande medelvärde (fönster 5)
val smooth = points.mapIndexed { i, _ ->
val from = (i - 2).coerceAtLeast(0)
val to = (i + 2).coerceAtMost(points.lastIndex)
val slice = points.subList(from, to + 1)
points[i].first to slice.map { it.second }.average().toFloat()
}
// Linjär regression för trendlinjen
val n = points.size.toFloat()
val meanX = points.map { it.first }.average().toFloat()
val meanY = points.map { it.second }.average().toFloat()
val denominator = points.sumOf { ((it.first - meanX) * (it.first - meanX)).toDouble() }
val slope = if (denominator == 0.0) 0f else {
(points.sumOf { ((it.first - meanX) * (it.second - meanY)).toDouble() } / denominator).toFloat()
}
val intercept = meanY - slope * meanX
Canvas(modifier = modifier.fillMaxWidth().height(160.dp)) {
fun x(v: Float) = (v - minX) / (maxX - minX).coerceAtLeast(1f) * size.width
fun y(w: Float) = size.height - (w - loW) / (hiW - loW) * size.height
// Trendlinje (streckad)
drawLine(
color = trendColor,
start = Offset(0f, y(slope * minX + intercept)),
end = Offset(size.width, y(slope * maxX + intercept)),
strokeWidth = 3f,
pathEffect = PathEffect.dashPathEffect(floatArrayOf(14f, 10f)),
)
// Mjuk linje (glidande medelvärde)
val path = Path()
smooth.forEachIndexed { i, (px, pw) ->
if (i == 0) path.moveTo(x(px), y(pw)) else path.lineTo(x(px), y(pw))
}
drawPath(path, color = lineColor, style = Stroke(width = 5f))
// Råpunkter
points.forEach { (px, pw) ->
drawCircle(color = pointColor, radius = 6f, center = Offset(x(px), y(pw)))
}
}
}

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<paths>
<cache-path name="updates" path="updates/" />
</paths>

View File

@@ -3,4 +3,5 @@ plugins {
alias(libs.plugins.kotlin.android) apply false alias(libs.plugins.kotlin.android) apply false
alias(libs.plugins.kotlin.compose) apply false alias(libs.plugins.kotlin.compose) apply false
alias(libs.plugins.kotlin.serialization) apply false alias(libs.plugins.kotlin.serialization) apply false
alias(libs.plugins.ksp) apply false
} }

645
doc/design-forslag-m2.html Normal file
View File

@@ -0,0 +1,645 @@
<title>FitnessDroid — designförslag</title>
<meta name="description" content="Passpilot-konceptet: mockups för passläge, hemskärm, övningsväljare, Android-ytor, kroppskarta och API-förslag.">
<style>
:root{
--ground:#0c0f0b; --panel:#131812; --panel-hi:#1a201a; --line:#263026;
--ink:#e4e7de; --dim:#96a093; --accent:#8fd694; --accent-deep:#2e4a33;
--m3-bg:#101410; --m3-surf:#1a211a; --m3-surf2:#232b23; --m3-surf3:#2b342b;
--m3-pri:#8fd694; --m3-onpri:#08300f; --m3-on:#e2e5dc; --m3-dim:#a9b2a5;
--m3-outline:#3e483d; --m3-seccont:#33503a; --m3-onsec:#cfeacf;
--heat0:#233026; --heat1:#3d5c42; --heat2:#5f8f60; --heat3:#8fd694; --heat4:#d3f0c9;
}
*{box-sizing:border-box;margin:0}
body{background:var(--ground);color:var(--ink);
font:16px/1.55 Roboto,system-ui,-apple-system,"Segoe UI",sans-serif;
-webkit-font-smoothing:antialiased;padding:0 20px 96px}
.wrap{max-width:1180px;margin:0 auto}
a{color:var(--accent)}
header.top{padding:64px 0 24px;border-bottom:1px solid var(--line);margin-bottom:8px}
.eyebrow{font-size:11px;letter-spacing:.16em;text-transform:uppercase;color:var(--accent);font-weight:600}
h1{font-size:clamp(28px,4.5vw,44px);font-weight:800;letter-spacing:-.02em;text-wrap:balance;margin:10px 0 12px}
.lede{color:var(--dim);max-width:66ch}
section{padding:52px 0 8px}
.secnum{font:600 12px/1 ui-monospace,monospace;color:var(--dim);letter-spacing:.1em}
h2{font-size:26px;font-weight:750;letter-spacing:-.01em;margin:8px 0 6px;text-wrap:balance}
.secintro{color:var(--dim);max-width:64ch;margin-bottom:28px}
h3{font-size:17px;font-weight:700;margin:0 0 4px}
/* koncept-kort */
.modes{display:flex;gap:14px;flex-wrap:wrap;margin-top:22px}
.mode{flex:1 1 240px;background:var(--panel);border:1px solid var(--line);border-radius:16px;padding:18px}
.mode b{display:block;font-size:15px;margin-bottom:5px}
.mode span{color:var(--dim);font-size:14px}
.mode .k{font:700 11px/1 ui-monospace,monospace;color:var(--accent);letter-spacing:.12em;display:block;margin-bottom:10px}
/* telefonram */
.phone{width:320px;background:#000;border:1px solid #2c342c;border-radius:30px;
padding:9px;flex:0 0 auto;box-shadow:0 24px 60px -30px rgba(0,0,0,.8)}
.screen{background:var(--m3-bg);border-radius:22px;overflow:hidden;display:flex;
flex-direction:column;height:640px;font-size:13.5px;color:var(--m3-on)}
.statusbar{display:flex;justify-content:space-between;align-items:center;
padding:10px 18px 4px;font-size:11px;color:var(--m3-dim)}
.sb-ic{display:flex;gap:5px;align-items:center}
.content{flex:1;overflow:hidden;padding:6px 14px 0;display:flex;flex-direction:column;gap:11px}
.gesture{height:18px;display:flex;align-items:center;justify-content:center}
.gesture i{display:block;width:96px;height:4px;border-radius:2px;background:#3a423a}
/* m3-bitar */
.m3-greet{padding:6px 4px 0}
.m3-greet b{font-size:21px;font-weight:700;display:block}
.m3-greet span{color:var(--m3-dim);font-size:12.5px}
.card{background:var(--m3-surf);border-radius:16px;padding:13px 14px}
.card.tonal{background:var(--m3-surf2)}
.rowline{display:flex;align-items:center;gap:10px}
.rowline .grow{flex:1;min-width:0}
.btn-filled{background:var(--m3-pri);color:var(--m3-onpri);border-radius:999px;
padding:13px 18px;font-weight:700;font-size:14.5px;text-align:center;
display:flex;align-items:center;justify-content:center;gap:8px}
.seclabel{display:flex;justify-content:space-between;align-items:baseline;padding:2px 4px 0}
.seclabel b{font-size:13.5px;font-weight:700}
.seclabel a{font-size:11.5px;color:var(--m3-pri);text-decoration:none;font-weight:600}
.hscroll{display:flex;gap:10px;overflow:hidden}
.favcard{background:var(--m3-surf2);border-radius:16px;padding:12px;min-width:150px;flex:0 0 auto}
.favcard .nm{font-weight:700;font-size:13.5px;display:flex;gap:6px;align-items:center}
.favcard .muscles{color:var(--m3-dim);font-size:10.5px;margin:3px 0 8px}
.favcard .go{margin-top:6px;background:var(--m3-pri);color:var(--m3-onpri);border-radius:999px;
font-size:11.5px;font-weight:700;padding:6px 0;text-align:center}
.star{color:#ffd977;font-size:12px}
.listrow{display:flex;gap:11px;align-items:center;padding:9px 4px;border-bottom:1px solid #202820}
.listrow:last-child{border-bottom:0}
.listrow .t{font-weight:600;font-size:13px}
.listrow .s{color:var(--m3-dim);font-size:11px}
.mchip{width:34px;height:34px;border-radius:50%;background:var(--m3-surf3);
display:flex;align-items:center;justify-content:center;flex:0 0 auto}
.mchip svg{width:19px;height:19px;stroke:var(--m3-pri);fill:none;stroke-width:1.7;
stroke-linecap:round;stroke-linejoin:round}
.badge{font-size:9.5px;font-weight:700;background:var(--m3-surf3);color:var(--m3-dim);
border-radius:5px;padding:2px 5px;letter-spacing:.03em}
.searchbar{background:var(--m3-surf2);border-radius:999px;padding:10px 15px;color:var(--m3-dim);
display:flex;gap:9px;align-items:center;font-size:13px}
.chiprow{display:flex;gap:7px;overflow:hidden;padding:1px}
.chip{border:1px solid var(--m3-outline);border-radius:9px;padding:6px 11px;font-size:11.5px;
font-weight:600;color:var(--m3-dim);display:inline-flex;gap:6px;align-items:center;flex:0 0 auto}
.chip svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:1.8;
stroke-linecap:round;stroke-linejoin:round}
.chip.sel{background:var(--m3-seccont);color:var(--m3-onsec);border-color:transparent}
.chip.small{padding:4px 10px;font-size:10.5px;border-radius:999px}
.topbar-menu{display:flex;justify-content:space-between;align-items:center;padding:2px 4px}
.topbar-menu b{font-size:16px}
/* passläget */
.sess-top{display:flex;justify-content:space-between;align-items:center;padding:2px 4px}
.sess-top b{font-size:15.5px}
.timerchip{background:var(--m3-seccont);color:var(--m3-onsec);border-radius:999px;
padding:4px 11px;font:700 12px/1.4 ui-monospace,monospace}
.pagerdots{display:flex;gap:5px;align-items:center;padding:0 4px}
.pagerdots i{height:4px;border-radius:2px;background:var(--m3-surf3);flex:1}
.pagerdots i.done{background:var(--m3-pri);opacity:.45}
.pagerdots i.cur{background:var(--m3-pri)}
.exhead{padding:2px 4px}
.exhead b{font-size:19px;font-weight:750;display:block}
.exhead span{color:var(--m3-dim);font-size:11.5px}
.setdone{display:flex;align-items:center;gap:10px;padding:7px 10px;border-radius:11px;
background:var(--m3-surf);color:var(--m3-dim);font-size:12.5px}
.setdone .ck{color:var(--m3-pri);font-weight:800}
.setactive{background:var(--m3-surf2);border-radius:16px;padding:13px;display:flex;
flex-direction:column;gap:11px}
.steppers{display:flex;gap:9px}
.stepper{flex:1;background:var(--m3-bg);border-radius:13px;padding:8px 9px;
display:flex;align-items:center;justify-content:space-between}
.stepper .pm{width:30px;height:30px;border-radius:50%;background:var(--m3-surf3);
color:var(--m3-on);font:700 16px/30px sans-serif;text-align:center}
.stepper .val{text-align:center}
.stepper .val b{font-size:17px;font-variant-numeric:tabular-nums}
.stepper .val span{display:block;font-size:9.5px;color:var(--m3-dim);letter-spacing:.08em;text-transform:uppercase}
.rpe{display:flex;gap:6px;align-items:center;font-size:10.5px;color:var(--m3-dim)}
.rpe i{font-style:normal;border:1px solid var(--m3-outline);border-radius:7px;
padding:3px 9px;font-weight:700;font-size:11px}
.rpe i.sel{background:var(--m3-seccont);color:var(--m3-onsec);border-color:transparent}
.nextup{display:flex;align-items:center;gap:9px;color:var(--m3-dim);font-size:11.5px;padding:0 4px}
/* vilo-overlay */
.rest{flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:16px}
.ring{width:190px;height:190px;border-radius:50%;position:relative;
background:conic-gradient(var(--m3-pri) 74%,var(--m3-surf3) 0)}
.ring::after{content:"";position:absolute;inset:12px;border-radius:50%;background:var(--m3-bg)}
.ring .mid{position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;
justify-content:center;z-index:1}
.ring .mid b{font:800 40px/1 ui-monospace,monospace;font-variant-numeric:tabular-nums}
.ring .mid span{color:var(--m3-dim);font-size:11px;letter-spacing:.1em;text-transform:uppercase}
.restbtns{display:flex;gap:10px}
.restbtns .btn-tonal{background:var(--m3-surf3);color:var(--m3-on);border-radius:999px;
padding:9px 16px;font-weight:700;font-size:12.5px}
.restnext{color:var(--m3-dim);font-size:12px;text-align:center}
.restnext b{color:var(--m3-on)}
/* strips: minispelare + notis */
.strips{display:flex;flex-direction:column;gap:18px;min-width:280px;flex:1 1 340px}
.striplabel{font-size:11px;letter-spacing:.14em;text-transform:uppercase;color:var(--dim);font-weight:700;margin-bottom:7px}
.stripbar{background:var(--m3-surf2);border:1px solid var(--line);border-radius:14px;
padding:10px 13px;display:flex;align-items:center;gap:11px;max-width:340px}
.stripbar .grow{flex:1;min-width:0}
.stripbar .t{font-weight:700;font-size:12.5px;color:var(--m3-on)}
.stripbar .s{font-size:10.5px;color:var(--m3-dim)}
.stripbar .act{color:var(--m3-pri);font-weight:800;font-size:11px}
.noti{background:#181d18;border:1px solid var(--line);border-radius:18px;padding:13px 15px;max-width:340px}
.noti .approw{display:flex;gap:7px;align-items:center;font-size:10px;color:var(--m3-dim);margin-bottom:7px}
.noti .approw i{width:13px;height:13px;border-radius:4px;background:var(--m3-pri);display:inline-block}
.noti b{font-size:13px;color:var(--m3-on)}
.noti p{font-size:12px;color:var(--m3-dim);margin:2px 0 9px}
.noti .actions{display:flex;gap:16px;font-weight:700;font-size:12px;color:var(--m3-pri)}
/* skivkalkylator */
.sheet{background:var(--m3-surf);border-radius:20px 20px 14px 14px;padding:16px;max-width:340px;border:1px solid var(--line)}
.sheet .grab{width:36px;height:4px;border-radius:2px;background:var(--m3-outline);margin:0 auto 12px}
.sheet h4{font-size:14px;margin-bottom:2px}
.sheet .sub{color:var(--m3-dim);font-size:11.5px;margin-bottom:13px}
.bar{display:flex;align-items:center;height:64px;margin:4px 0 12px}
.bar .shaft{height:7px;background:#5a635a;flex:1;border-radius:3px}
.bar .collar{width:9px;height:22px;background:#788078;border-radius:2px}
.plate{border-radius:4px;margin:0 1.5px;box-shadow:inset 0 0 0 1.5px rgba(0,0,0,.35)}
.p20{width:13px;height:62px;background:#3f6fb5}
.p10{width:11px;height:46px;background:#4d9b57}
.p5{width:10px;height:34px;background:#d7d7cf}
.p25{width:9px;height:26px;background:#c05252}
.sheet .plates-txt{font-size:12.5px;color:var(--m3-dim)}
.sheet .plates-txt b{color:var(--m3-on)}
/* widget */
.widget{background:linear-gradient(145deg,#1c231c,#141a14);border:1px solid var(--line);
border-radius:22px;padding:14px 16px;max-width:340px}
.widget .wt{display:flex;justify-content:space-between;align-items:center;margin-bottom:11px}
.widget .wt b{font-size:12.5px;color:var(--m3-on)}
.widget .wt span{font-size:10px;color:var(--m3-dim)}
.widget .wbtns{display:flex;gap:8px}
.widget .wb{flex:1;border-radius:12px;padding:10px 8px;font-size:11.5px;font-weight:700;text-align:center}
.widget .wb.pri{background:var(--m3-pri);color:var(--m3-onpri)}
.widget .wb.ton{background:var(--m3-surf3);color:var(--m3-on)}
/* kroppskarta */
.bodymaps{display:flex;gap:34px;flex-wrap:wrap;align-items:flex-start}
.bodycard{background:var(--panel);border:1px solid var(--line);border-radius:18px;padding:22px 26px;display:flex;gap:26px}
.bodycard figure{text-align:center}
.bodycard figcaption{font-size:11px;color:var(--dim);margin-top:8px;letter-spacing:.08em;text-transform:uppercase;font-weight:700}
.heatlegend{display:flex;align-items:center;gap:8px;font-size:11.5px;color:var(--dim);margin-top:16px}
.heatlegend i{width:22px;height:10px;border-radius:3px;display:inline-block}
/* layout runt telefoner */
.phones{display:flex;gap:26px;flex-wrap:wrap;align-items:flex-start}
.duo{display:flex;gap:40px;flex-wrap:wrap;align-items:flex-start}
.notes{flex:1 1 340px;min-width:280px;display:flex;flex-direction:column;gap:16px}
.note{display:flex;gap:13px;align-items:flex-start}
.note .dot{width:24px;height:24px;border-radius:50%;background:var(--accent);
color:#0a2e10;font:700 12.5px/24px ui-monospace,monospace;text-align:center;flex:0 0 auto}
.note p{color:var(--dim);font-size:14.5px}
.note b{color:var(--ink)}
.phcaption{font-size:11px;letter-spacing:.12em;text-transform:uppercase;color:var(--dim);
font-weight:700;text-align:center;margin-top:12px}
table{border-collapse:collapse;width:100%;font-size:13.5px;margin-top:22px}
th,td{text-align:left;padding:9px 12px;border-bottom:1px solid var(--line);vertical-align:top}
th{color:var(--dim);font-size:11px;text-transform:uppercase;letter-spacing:.1em}
td:first-child{font-weight:600;white-space:nowrap;color:var(--ink)}
td{color:var(--dim)}
.tablewrap{overflow-x:auto}
.pill-sm{display:inline-block;font-size:10px;font-weight:800;border-radius:5px;padding:2px 7px;
letter-spacing:.06em}
.pill-sm.s{background:var(--accent-deep);color:var(--accent)}
.pill-sm.m{background:#4a3f22;color:#e8c96e}
.decide{background:var(--panel);border:1px solid var(--line);border-radius:16px;
padding:22px 24px;margin-top:44px}
.decide h2{font-size:19px;margin:0 0 10px}
.decide ol{margin:0;padding-left:20px;color:var(--dim);display:flex;flex-direction:column;gap:8px;font-size:14.5px}
.decide b{color:var(--ink)}
</style>
<div class="wrap">
<header class="top">
<div class="eyebrow">FitnessDroid · Milstolpe 2 · Designförslag v2</div>
<h1>En passpilot i fickan — inte webben i mindre format</h1>
<p class="lede">Nytt tänk: appen byggs runt <b>själva gympasset</b>, inte runt flikar.
Mellan seten har du svettiga händer och 90 sekunder — allt i passläget är därför
stora tryckytor, förifyllda värden och en vilotimer som sköter sig själv, även på
låsskärmen. Runt det: en hemskärm som startar pass på ett tryck, och Android-ytor
som webben aldrig kan nå.</p>
<div class="modes">
<div class="mode"><span class="k">LÄGE 1 · STARTA</span><b>Hemskärmen</b>
<span>Favoritpass, fritt pass, widget och appgenvägar — max två tryck till första setet.</span></div>
<div class="mode"><span class="k">LÄGE 2 · KÖR</span><b>Passläget</b>
<span>En övning i taget, set loggas med ett tryck, vilotimern tar över — appen coachar, du lyfter.</span></div>
<div class="mode"><span class="k">LÄGE 3 · FÖLJ UPP</span><b>Statistik &amp; PB</b>
<span>Kroppskarta som visar var veckans volym hamnat, trender och PB-historik.</span></div>
</div>
</header>
<!-- ============ 1. PASSLÄGET ============ -->
<section>
<div class="secnum">1 / 6</div>
<h2>Passläget — en övning i taget, ett tryck per set</h2>
<p class="secintro">Hela skärmen ägnas åt övningen du står vid. Svep i sidled mellan
övningarna i passet. Värdena är alltid förifyllda från förra passet plus
progressionsregeln — stämmer allt trycker du bara på gröna knappen.</p>
<div class="duo">
<div>
<div class="phone"><div class="screen">
<div class="statusbar"><span>19:24</span><span class="sb-ic">▲ ▮</span></div>
<div class="content">
<div class="sess-top">
<span style="display:flex;align-items:center;gap:9px">
<svg width="18" height="18" viewBox="0 0 24 24" style="stroke:var(--m3-on);fill:none;stroke-width:1.8;stroke-linecap:round"><path d="M15 5l-7 7 7 7"/></svg>
<b>Push A</b></span>
<span class="timerchip">24:31</span>
</div>
<div class="pagerdots"><i class="done"></i><i class="done"></i><i class="cur"></i><i></i><i></i><i></i><i></i><i></i></div>
<div class="exhead"><b>Bänkpress</b>
<span>Övning 3 av 8 · Bröst, triceps · Förra passet: 4×8 @ 77,5 kg</span></div>
<div class="setdone"><span class="ck"></span>Set 1 · 80,0 kg × 8 · RPE 7</div>
<div class="setdone"><span class="ck"></span>Set 2 · 80,0 kg × 8 · RPE 8</div>
<div class="setactive">
<div style="font-size:11px;color:var(--m3-dim);font-weight:700;letter-spacing:.08em">SET 3 AV 4</div>
<div class="steppers">
<div class="stepper"><span class="pm"></span>
<span class="val"><b>80,0</b><span>kg · ±2,5</span></span><span class="pm">+</span></div>
<div class="stepper"><span class="pm"></span>
<span class="val"><b>8</b><span>reps</span></span><span class="pm">+</span></div>
</div>
<div class="rpe">RPE <i>7</i><i class="sel">8</i><i>9</i><i>10</i></div>
<div class="btn-filled">✓ Logga set — vilan startar</div>
</div>
<div class="nextup">
<svg width="14" height="14" viewBox="0 0 24 24" style="stroke:currentColor;fill:none;stroke-width:1.8;stroke-linecap:round"><path d="M5 12h14m-6-6 6 6-6 6"/></svg>
Nästa: Lutande hantelpress · 3×10 @ 28 kg</div>
</div>
<div class="gesture"><i></i></div>
</div></div>
<div class="phcaption">Passläget · aktivt set</div>
</div>
<div>
<div class="phone"><div class="screen">
<div class="statusbar"><span>19:26</span><span class="sb-ic">▲ ▮</span></div>
<div class="content">
<div class="sess-top"><b style="font-size:14px">Vila</b><span class="timerchip">Push A · 26:04</span></div>
<div class="rest">
<div class="ring"><div class="mid"><b>1:07</b><span>av 1:30</span></div></div>
<div class="restbtns">
<span class="btn-tonal">15 s</span>
<span class="btn-tonal">+15 s</span>
<span class="btn-tonal">Hoppa över</span>
</div>
<div class="restnext">Härnäst: <b>Set 4 av 4 · 80,0 kg × 8</b><br>
Sista setet på bänken — sen lutande hantelpress</div>
</div>
</div>
<div class="gesture"><i></i></div>
</div></div>
<div class="phcaption">Vilan tar över skärmen</div>
</div>
<div class="notes">
<div class="note"><div class="dot">1</div><p><b>Ett tryck per set.</b> Vikt och
reps förifylls från förra passet + progressionsregeln (API:t har redan
<i>lastExercisePerformance</i>). Stämmer det: tryck ✓. Annars ±-knappar —
aldrig tangentbord för standardfallet.</p></div>
<div class="note"><div class="dot">2</div><p><b>Vilan sköter sig själv.</b> Loggat
set → timern startar, skärmen växlar till ringen, telefonen vibrerar när det
är dags. Längden hämtas per övning (litet API-tillägg, se sist).</p></div>
<div class="note"><div class="dot">3</div><p><b>Svep = nästa övning.</b> Ingen
lista att scrolla i mitt i passet. Prickraden överst visar var i passet du är;
långtryck öppnar överblicken där du kan ändra ordning eller lägga till övningar.</p></div>
<div class="note"><div class="dot">4</div><p><b>PB firas direkt</b> — slår du
ett personbästa på ett set får du konfetti och en 🏆-notis i samma sekund,
inte när passet är slut.</p></div>
</div>
</div>
<div style="margin-top:38px" class="duo">
<div class="strips">
<div><div class="striplabel">Minispelare — passet följer med i hela appen (som Spotify)</div>
<div class="stripbar">
<svg width="18" height="18" viewBox="0 0 24 24" style="fill:var(--m3-pri)"><path d="M8 5v14l11-7z"/></svg>
<div class="grow"><div class="t">Push A · Bänkpress</div><div class="s">Vila 0:47 kvar · set 4 av 4</div></div>
<span class="act">ÖPPNA</span>
</div></div>
<div><div class="striplabel">Notis på låsskärmen — timern utan att ta upp telefonen ur fickan</div>
<div class="noti">
<div class="approw"><i></i> FitnessDroid · pågår</div>
<b>Vila 0:47 — sen set 4 av 4</b>
<p>Bänkpress 80,0 kg × 8 · Push A 26:04</p>
<div class="actions"><span>+15 s</span><span>LOGGA SET</span><span>PAUSA PASS</span></div>
</div></div>
</div>
<div class="notes" style="justify-content:center">
<div class="note"><div class="dot">5</div><p><b>Lämna passläget utan att tappa
passet.</b> Kollar du statistik mitt i passet ligger minispelaren kvar ovanför
navigeringen. Notisen med timer-åtgärder gör att telefonen kan ligga kvar i
fickan mellan seten — <b>det här är appens största övertag över webben.</b></p></div>
</div>
</div>
</section>
<!-- ============ 2. HEMSKÄRM ============ -->
<section>
<div class="secnum">2 / 6</div>
<h2>Hemskärmen — startmotorn</h2>
<p class="secintro">Öppnar du appen med ett pass igång åker du rakt in i passläget.
Annars: starta på ett eller två tryck.</p>
<div class="duo">
<div>
<div class="phone"><div class="screen">
<div class="statusbar"><span>19:24</span><span class="sb-ic">▲ ▮</span></div>
<div class="content">
<div class="m3-greet"><b>Tjena Björn</b><span>Vecka 30 · 2 pass · 3 veckors streak 🔥</span></div>
<div class="btn-filled" style="padding:15px 18px"><svg width="18" height="18" viewBox="0 0 24 24" style="fill:currentColor"><path d="M8 5v14l11-7z"/></svg> Starta fritt pass</div>
<div class="seclabel"><b>Favoritpass</b><a>Alla mallar</a></div>
<div class="hscroll">
<div class="favcard">
<div class="nm"><span class="star"></span>Push A</div>
<div class="muscles">Bröst · Axlar · Triceps</div>
<div class="go">▶ Starta</div>
</div>
<div class="favcard">
<div class="nm"><span class="star"></span>Ben &amp; core</div>
<div class="muscles">Ben · Mage</div>
<div class="go">▶ Starta</div>
</div>
<div class="favcard">
<div class="nm"><span class="star"></span>Rygg</div>
<div class="muscles">Rygg · Biceps</div>
<div class="go">▶ Starta</div>
</div>
</div>
<div class="card tonal rowline">
<div class="mchip"><svg viewBox="0 0 24 24"><path d="M4 19V9m6 10V5m6 14v-7"/></svg></div>
<div class="grow"><div style="font-weight:700;font-size:12.5px">Veckans muskler</div>
<div style="font-size:10.5px;color:var(--m3-dim)">Mest bröst &amp; triceps — benen släpar efter</div></div>
<svg width="30" height="44" viewBox="0 0 30 46" aria-hidden="true">
<circle cx="15" cy="5" r="4" fill="#3d5c42"/>
<rect x="7" y="11" width="16" height="15" rx="5" fill="#8fd694"/>
<rect x="2" y="12" width="4.5" height="12" rx="2" fill="#5f8f60"/>
<rect x="23.5" y="12" width="4.5" height="12" rx="2" fill="#5f8f60"/>
<rect x="8" y="27" width="6" height="16" rx="2.6" fill="#233026"/>
<rect x="16" y="27" width="6" height="16" rx="2.6" fill="#233026"/>
</svg>
</div>
<div class="seclabel"><b>Senaste pass</b><a>Historik</a></div>
<div>
<div class="listrow"><div class="mchip"><svg viewBox="0 0 24 24"><path d="M4 12h16M7 8v8m10-8v8M4 10v4m16-4v4"/></svg></div>
<div class="grow"><div class="t">Push A</div><div class="s">mån 21/7 · 52 min · 4 890 kg</div></div>
<span class="badge">2 PB</span></div>
</div>
</div>
<div class="gesture"><i></i></div>
</div></div>
<div class="phcaption">Hemskärmen · inget pass igång</div>
</div>
<div class="notes">
<div class="note"><div class="dot">1</div><p><b>Fritt pass = ett tryck.</b>
Passet skapas direkt och övningsväljaren öppnas. Namn kan sättas efteråt —
inga formulär mellan dig och första setet.</p></div>
<div class="note"><div class="dot">2</div><p><b>Favoritpass = två tryck.</b>
API:ts favoritpass + mallar, med auto-progressionen förifylld.</p></div>
<div class="note"><div class="dot">3</div><p><b>Veckans muskler</b> — en liten
kroppskarta som teaser för statistiken, och en knuff om vad som är underjobbat.
Trycker du på kortet öppnas hela kartan (sektion 5).</p></div>
<div class="note"><div class="dot">4</div><p><b>Pågår ett pass öppnas appen
direkt i passläget</b> — hemskärmen visas bara när inget är igång. Därför
behövs inget "fortsätt"-kort: appen minns åt dig.</p></div>
</div>
</div>
</section>
<!-- ============ 3. ÖVNINGSVÄLJAREN ============ -->
<section>
<div class="secnum">3 / 6</div>
<h2>Övningsväljaren — sök, muskelfilter, favoriter först</h2>
<p class="secintro">Muskelgrupper som chips med ikoner; vald grupp fäller ut sina
muskler som en andra rad. Brickorna (KG/REPS/TID/M) kommer från övningens
tracks-flaggor och styr sedan set-loggningens fält.</p>
<div class="duo">
<div>
<div class="phone"><div class="screen">
<div class="statusbar"><span>19:24</span><span class="sb-ic">▲ ▮</span></div>
<div class="content">
<div class="topbar-menu"><span style="display:flex;align-items:center;gap:10px">
<svg width="20" height="20" viewBox="0 0 24 24" style="stroke:var(--m3-on);fill:none;stroke-width:1.8;stroke-linecap:round"><path d="M15 5l-7 7 7 7"/></svg>
<b>Lägg till övning</b></span></div>
<div class="searchbar"><svg width="15" height="15" viewBox="0 0 24 24" style="stroke:currentColor;fill:none;stroke-width:1.8"><circle cx="11" cy="11" r="6"/><path d="m20 20-4-4"/></svg> Sök övning …</div>
<div class="chiprow">
<span class="chip">Alla</span>
<span class="chip"><svg viewBox="0 0 24 24"><path d="M6 4h12l-1.5 8a4.5 4.5 0 0 1-9 0z"/><path d="M12 12v8"/></svg>Bröst</span>
<span class="chip sel"><svg viewBox="0 0 24 24"><path d="M7 4v7l5 9 5-9V4"/><path d="M7 8h10"/></svg>Rygg</span>
<span class="chip"><svg viewBox="0 0 24 24"><path d="M9 4v6l-2.5 9h4L12 13l1.5 6h4L15 10V4"/></svg>Ben</span>
<span class="chip"><svg viewBox="0 0 24 24"><path d="M4 12a8 8 0 0 1 16 0"/><circle cx="4" cy="14" r="2"/><circle cx="20" cy="14" r="2"/></svg>Axlar</span>
<span class="chip"><svg viewBox="0 0 24 24"><path d="M5 18l5-5m0 0 5-8 4 3-5 7-4-2z"/><circle cx="5" cy="19" r="2"/></svg>Armar</span>
</div>
<div class="chiprow">
<span class="chip small sel">Alla i Rygg</span>
<span class="chip small">Latsen</span>
<span class="chip small">Trapezius</span>
<span class="chip small">Ländrygg</span>
</div>
<div style="flex:1">
<div class="listrow"><div class="mchip"><svg viewBox="0 0 24 24"><path d="M4 12h16M7 8v8m10-8v8M4 10v4m16-4v4"/></svg></div>
<div class="grow"><div class="t">Marklyft <span class="star"></span></div><div class="s">Rygg · Ben · senast 77,5 kg</div></div>
<span class="badge">KG</span><span class="badge">REPS</span></div>
<div class="listrow"><div class="mchip"><svg viewBox="0 0 24 24"><path d="M7 4v7l5 9 5-9V4"/><path d="M7 8h10"/></svg></div>
<div class="grow"><div class="t">Latsdrag <span class="star"></span></div><div class="s">Latsen · senast 65 kg</div></div>
<span class="badge">KG</span><span class="badge">REPS</span></div>
<div class="listrow"><div class="mchip"><svg viewBox="0 0 24 24"><path d="M7 4v7l5 9 5-9V4"/><path d="M7 8h10"/></svg></div>
<div class="grow"><div class="t">Skivstångsrodd</div><div class="s">Rygg · Biceps</div></div>
<span class="badge">KG</span><span class="badge">REPS</span></div>
<div class="listrow"><div class="mchip"><svg viewBox="0 0 24 24"><path d="M4 6h16M8 6v4a4 4 0 0 0 8 0V6"/><path d="M12 14v6"/></svg></div>
<div class="grow"><div class="t">Pullups</div><div class="s">Latsen · kroppsvikt</div></div>
<span class="badge">REPS</span></div>
<div class="listrow"><div class="mchip"><svg viewBox="0 0 24 24"><path d="M7 4v7l5 9 5-9V4"/><path d="M7 8h10"/></svg></div>
<div class="grow"><div class="t">Ryggresning</div><div class="s">Ländrygg</div></div>
<span class="badge">REPS</span><span class="badge">TID</span></div>
</div>
</div>
<div class="gesture"><i></i></div>
</div></div>
<div class="phcaption">Övningsväljaren · Rygg valt</div>
</div>
<div class="notes">
<div class="note"><div class="dot"></div><p><b>Favoriter alltid överst</b>
(API:ts favoritövningar), med senaste vikten som förhandsinfo så du slipper
minnas var du låg.</p></div>
<div class="note"><div class="dot"></div><p><b>Ikoner istället för foton:</b>
API:t saknar övningsbilder, så appen buntar vektorikoner per muskelgrupp —
offline, blixtsnabba, temafärgade. Vill du hellre ha en <b>kroppskarta där
vald muskel tänds</b> som filter är det samma dataunderlag — säg till vilken
du föredrar (kartan finns i sektion 5).</p></div>
<div class="note"><div class="dot">+</div><p><b>Ny övning på plats:</b> hittas
inte övningen visas "Skapa övningen sökordet" längst ned — grunddatan
behöver aldrig besökas mitt i ett pass.</p></div>
</div>
</div>
</section>
<!-- ============ 4. ANDROID-YTOR ============ -->
<section>
<div class="secnum">4 / 6</div>
<h2>Utanför appen — noll tryck innan gymmet</h2>
<p class="secintro">Det webben aldrig kan: starta och styra pass utan att ens öppna appen.</p>
<div class="duo">
<div class="strips">
<div><div class="striplabel">Hemskärms-widget (4×2)</div>
<div class="widget">
<div class="wt"><b>FitnessDroid</b><span>3 v streak · 2 pass denna vecka</span></div>
<div class="wbtns">
<span class="wb pri">▶ Push A</span>
<span class="wb ton">▶ Ben &amp; core</span>
<span class="wb ton">Fritt pass</span>
</div>
</div></div>
<div><div class="striplabel">Långtryck på appikonen — genvägar</div>
<div class="noti" style="padding:9px 0;max-width:250px">
<div style="font-size:12.5px;font-weight:700;color:var(--m3-on);padding:6px 16px">▶ Starta Push A</div>
<div style="font-size:12.5px;font-weight:700;color:var(--m3-on);padding:6px 16px;border-top:1px solid var(--line)">▶ Starta fritt pass</div>
<div style="font-size:12.5px;font-weight:700;color:var(--m3-on);padding:6px 16px;border-top:1px solid var(--line)">📊 Veckans statistik</div>
</div></div>
</div>
<div class="strips">
<div><div class="striplabel">Skivkalkylatorn — tryck på vikten i passläget</div>
<div class="sheet">
<div class="grab"></div>
<h4>80,0 kg på stången</h4>
<div class="sub">Stång 20 kg · per sida:</div>
<div class="bar">
<span class="plate p5"></span><span class="plate p25"></span><span class="plate p10"></span><span class="plate p20"></span>
<span class="collar"></span><span class="shaft"></span><span class="collar"></span>
<span class="plate p20"></span><span class="plate p10"></span><span class="plate p25"></span><span class="plate p5"></span>
</div>
<div class="plates-txt">Per sida: <b>20 + 10 + 2,5 + 5</b> — dina skivor
ställs in en gång under Profil. Räknar även ut närmsta möjliga vikt.</div>
</div></div>
</div>
</div>
</section>
<!-- ============ 5. KROPPSKARTAN ============ -->
<section>
<div class="secnum">5 / 6</div>
<h2>Kroppskartan — var hamnade veckans volym?</h2>
<p class="secintro">Statistikens signaturvy: en stiliserad figur där varje muskelgrupp
färgas efter träningsvolymen i perioden (datat finns redan i API:ts workoutStats).
Samma karta kan återanvändas som muskelväljare i övningsfiltret om du hellre vill
det än chips.</p>
<div class="bodymaps">
<div class="bodycard">
<figure>
<svg width="150" height="290" viewBox="0 0 100 195" role="img" aria-label="Kroppskarta framsida">
<circle cx="50" cy="12" r="9" fill="#233026"/>
<rect x="43" y="22" width="14" height="7" rx="3" fill="#233026"/>
<ellipse cx="35" cy="36" rx="9" ry="7" fill="#5f8f60"/>
<ellipse cx="65" cy="36" rx="9" ry="7" fill="#5f8f60"/>
<rect x="33" y="34" width="15" height="14" rx="6" fill="#8fd694"/>
<rect x="52" y="34" width="15" height="14" rx="6" fill="#8fd694"/>
<rect x="36" y="50" width="28" height="26" rx="8" fill="#3d5c42"/>
<rect x="22" y="40" width="8" height="24" rx="4" fill="#d3f0c9"/>
<rect x="70" y="40" width="8" height="24" rx="4" fill="#d3f0c9"/>
<rect x="21" y="66" width="7" height="20" rx="3.5" fill="#3d5c42"/>
<rect x="72" y="66" width="7" height="20" rx="3.5" fill="#3d5c42"/>
<rect x="34" y="80" width="13" height="42" rx="6" fill="#233026"/>
<rect x="53" y="80" width="13" height="42" rx="6" fill="#233026"/>
<rect x="36" y="126" width="10" height="30" rx="5" fill="#233026"/>
<rect x="54" y="126" width="10" height="30" rx="5" fill="#233026"/>
</svg>
<figcaption>Framsida</figcaption>
</figure>
<figure>
<svg width="150" height="290" viewBox="0 0 100 195" role="img" aria-label="Kroppskarta baksida">
<circle cx="50" cy="12" r="9" fill="#233026"/>
<rect x="40" y="22" width="20" height="10" rx="4" fill="#5f8f60"/>
<rect x="34" y="33" width="32" height="22" rx="8" fill="#3d5c42"/>
<rect x="38" y="57" width="24" height="18" rx="7" fill="#233026"/>
<rect x="22" y="40" width="8" height="24" rx="4" fill="#3d5c42"/>
<rect x="70" y="40" width="8" height="24" rx="4" fill="#3d5c42"/>
<rect x="21" y="66" width="7" height="20" rx="3.5" fill="#233026"/>
<rect x="72" y="66" width="7" height="20" rx="3.5" fill="#233026"/>
<rect x="35" y="77" width="30" height="14" rx="6" fill="#233026"/>
<rect x="34" y="93" width="13" height="32" rx="6" fill="#233026"/>
<rect x="53" y="93" width="13" height="32" rx="6" fill="#233026"/>
<rect x="36" y="128" width="10" height="28" rx="5" fill="#3d5c42"/>
<rect x="54" y="128" width="10" height="28" rx="5" fill="#3d5c42"/>
</svg>
<figcaption>Baksida</figcaption>
</figure>
</div>
<div class="notes" style="justify-content:center">
<div class="note"><div class="dot"></div><p><b>Färgskalan är volym i perioden</b>
— här syns direkt att bröst/underarmar fått mest och att ben &amp; mage släpar.
Tryck på en muskel → övningarna och seten bakom siffran.</p></div>
<div class="heatlegend">Mindre
<i style="background:var(--heat0)"></i><i style="background:var(--heat1)"></i>
<i style="background:var(--heat2)"></i><i style="background:var(--heat3)"></i>
<i style="background:var(--heat4)"></i> Mer volym</div>
<div class="note" style="margin-top:10px"><div class="dot"></div><p>Figuren är
medvetet stiliserad (byggd av former, inte anatomisk) — den blir tydlig i litet
format, temafärgas och kräver inga bildlicenser. Vill du ha en mer anatomisk
siluett går det, men liten och skarp slår detaljerad och grötig på en telefon.</p></div>
</div>
</div>
</section>
<!-- ============ 6. NAVIGERING + API ============ -->
<section>
<div class="secnum">6 / 6</div>
<h2>Navigeringen — chrome runt tre lägen</h2>
<p class="secintro">Med passläge + minispelare behövs färre flikar än webben har:
<b>Hem · Historik · Statistik · Profil</b> med startknappen i mitten (förslag B
från förra rundan). PB och kroppskartan bor i Statistik, grunddata och
skivinställningar under Profil. Fritt pass, favoriter, widget och appgenvägar
gör att flikarna nästan aldrig behövs på väg <i>in</i> i ett pass — de är för
uppföljningen.</p>
<h2 style="margin-top:36px">Små API-tillägg som lyfter appen</h2>
<p class="secintro">Inget av detta blockerar bygget — appen kan falla tillbaka på
vettiga defaultvärden tills de finns.</p>
<div class="tablewrap"><table>
<tr><th>Tillägg</th><th>Vad det ger</th><th>Storlek</th></tr>
<tr><td>MuscleGroup.iconKey</td>
<td>Servern pekar ut vilken ikon en muskelgrupp har istället för att appen
gissar på namnsträngar ("Bröst" vs "Chest"). En kolumn + fält i GraphQL.</td>
<td><span class="pill-sm s">LITEN</span></td></tr>
<tr><td>ExerciseType.defaultRestSeconds</td>
<td>Auto-vilotimern vet hur länge just den övningen vilar (90 s bänk, 180 s
marklyft). Fallback i appen: 90 s. Kolumn + fält + med i mallexport.</td>
<td><span class="pill-sm s">LITEN</span></td></tr>
<tr><td>Lift/Set: clientId (UUID)</td>
<td>Offline-läge på gymmet: appen kan köa set lokalt och synka i efterhand utan
dubbletter när samma mutation skickas om. Unik kolumn + upsert-logik.</td>
<td><span class="pill-sm m">MEDEL · roadmap</span></td></tr>
<tr><td>ExerciseType.imageUrl</td>
<td>Frivillig påbyggnad om du senare vill ha riktiga foton/anatomibilder per
övning — appen visar dem när de finns, ikoner annars.</td>
<td><span class="pill-sm s">LITEN · valfri</span></td></tr>
</table></div>
<div class="decide">
<h2>Det jag behöver från dig</h2>
<ol>
<li><b>Passläget:</b> köper du "en övning i taget + svep + vilo-takeover", eller
vill du hellre se hela passet som scrollbar lista med allt synligt?</li>
<li><b>Muskelfiltret:</b> chips med ikoner (sektion 3) eller kroppskartan
(sektion 5) som väljare — eller båda (chips snabbt, karta bakom en knapp)?</li>
<li><b>Android-ytorna:</b> vilka vill du ha i första versionen — notistimer,
widget, appgenvägar, skivkalkylator? (Notistimern föreslår jag ingår direkt,
resten kan komma stegvis.)</li>
<li><b>API-tilläggen:</b> ok att jag gör iconKey + defaultRestSeconds i
gym-API:t (byggs och rullas ut via befintliga CI:t)?</li>
</ol>
</div>
</div>

View File

@@ -0,0 +1,100 @@
# openScale BT-vågar — integrationsplan
Bygga in [openScale](https://github.com/oliexdev/openScale):s Bluetooth-
drivrutiner i FitnessDroid så att vägningar (vikt + %muskler/fett/vatten)
kan hämtas direkt från en BT-våg in i appens kroppsmätningar.
## Beslut (2026-07-26)
- **Licens: FitnessDroid relicensas till GPL-3.0.** openScale är GPL-3.0;
att bädda in deras kod kräver att hela appen blir GPL-3.0. LICENSE byts,
och källfilerna som kommer från openScale behåller sina copyright-headers.
- **Alla drivrutiner portas** (hela `core/bluetooth`-paketet, ~55 handlers +
adaptrar), inte bara en modell. `ScaleFactory` auto-detekterar vågen vid
skanning precis som i openScale.
- **Attribution:** en **About**-vy under Profil som anger att appen använder
openScales drivrutiner, med länk till deras GitHub och GPL-3.0-licensen,
plus en lista på öppen källkod som används (openScale, Blessed-Kotlin).
## Teknik (bekräftat från repot)
- openScale använder **Blessed-Kotlin** (coroutine-BLE-wrapper) — läggs till
som Gradle-beroende; vi portar drivrutinerna som ligger ovanpå, inte
BLE-plumbingen.
- `ScaleDeviceHandler.supportFor(device)``DeviceSupport` (displayName,
linkMode, tuningProfile). `ScaleFactory` returnerar första matchande handler.
- Tre kopplingslägen: `CONNECT_GATT`, `BROADCAST_ONLY`, `CLASSIC_SPP` med var
sin adapter (Gatt/Broadcast/Spp).
- `ScaleMeasurement` bär weight, fat, water, muscle, visceralFat, bone, lbm,
bmr, protein, impedance m.m. → vi mappar weight + muscle/fat/water till vår
`BodyMeasurement` (resten kan tas in senare).
- minSdk 31 matchar vår app (BLE-permissions `BLUETOOTH_SCAN`/`_CONNECT`).
## Fallgrop: impedansvågar behöver användarprofil
Vissa vågar skickar bara **rå impedans**; openScale räknar då själv ut
fett/muskler/vatten med en formel som kräver **längd, ålder, kön** (ScaleUser).
Vi måste därför:
- Lägga till längd/födelseår/kön i profilen (se API-ändring nedan).
- Porta openScales BIA-beräkning för de vågar som kräver den.
Vågar som räknar ombord och skickar färdiga procent behöver inte detta.
## API-ändring: spara användarens längd (+ ålder/kön)
Längden ska sparas **på servern** (gym-API:t) så att den är gemensam för app
och webb, överlever ominstallation, och kan användas för mer korrekta
beräkningar:
- **BIA/kroppssammansättning:** openScales formler för impedansvågar kräver
längd (+ ålder + kön) för att ge korrekta %fett/muskler/vatten.
- **Energiförbrukning:** med längd (+ ålder + kön) kan kaloriberäkningen gå
från ren MET × vikt × tid till en BMR-baserad uppskattning
(MifflinSt Jeor: `10·vikt + 6.25·längd 5·ålder + könskonstant`), vilket
ger rimligare siffror per pass.
**Backend (brasse-pc.eu-v2, gym-api):**
- Utöka `User` med `HeightCm` (double?), och för full BIA även `BirthDate`
(eller `BirthYear`) och `Sex` (enum/sträng). Nullable + bakåtkompatibla.
- Migration (körs automatiskt vid uppstart, samma mönster som
`BodyMeasurement`).
- Exponera i `myProfile`-query och en `updateProfile`-mutation (eller utöka
`updateBodyWeight``updateProfile` med längd/ålder/kön).
- Deploya om gym-api-prod + -test (serialiserat bygge, se
[[pi5-ci-concurrency]]).
**App:** profil-fält för längd (och ålder/kön) som läser/skriver mot API:t;
cachas lokalt (som kroppsvikten) för offline och för BIA-beräkningen.
**Webb (brasse-pc.eu-v2):** samma fält på profil-fliken via `updateProfile`.
## Milstolpar (byggbara steg)
1. **Grund + licens + About** — LICENSE → GPL-3.0; `blessed-kotlin`-beroende;
BLE-permissions i manifestet; About-vy under Profil (attribution + länkar +
openetlicens-text); körruntime-permission-flöde. *Ingen vågkod än — bara
att allt kompilerar och About visas.*
2. **Porta bluetooth-paketet** — vendora `com.health.openscale.core.bluetooth`
(ScaleCommunicator, ScaleFactory, scales/, adaptrar, data/, libs/) med
copyright-headers kvar. Slimmad `ScaleMeasurement`/`ScaleUser`. Får det att
kompilera mot Blessed.
3. **Skanna + koppla** — "Anslut våg" i inställningar: skanna BLE, kör
`ScaleFactory` mot varje enhet, visa matchade vågar, spara vald MAC.
4. **Väg dig-flöde** — knapp som skannar/kopplar vald våg, tar emot en
`ScaleMeasurement`, förhandsvisar och sparar via `addBodyMeasurement`
(offline-kön tar resten). Live-status ("står på vågen…").
5. **API: längd (+ ålder/kön)** — utöka gym-API:ts `User` med `HeightCm`
(+ `BirthDate`/`Sex`), migration, `myProfile` + `updateProfile`, deploy.
App + webb får profilfält. Se "API-ändring" ovan.
6. **Impedansvågar + bättre kalorier** — BIA-beräkning för vågar som skickar
rå impedans (använder längd/ålder/kön), och BMR-baserad energiförbrukning.
Verifieras mot din våg.
7. **Polering + docs** — felhantering, timeouts, ominställning av våg;
uppdatera README/wiki + infra-Doc (GPL-relicens noterad).
## Öppna frågor / risker
- **Vilken våg du har** avgör om milstolpe 5 (impedans-BIA) behövs eller om
din våg skickar färdiga procent. Identifieras i milstolpe 3 via skanning.
- Blessed-Kotlins exakta koordinater/version verifieras vid milstolpe 1.
- APK växer (drivrutiner + BLE-lib) — troligen någon MB, oproblematiskt.
- GPL-relicens är permanent för koden; medvetet val.

View File

@@ -28,13 +28,44 @@ mallar, grunddata och profil.
## Milstolpar ## Milstolpar
1. **Grund + login** *(klar i och med första commit)* 1. **Grund + login** *(klar 2026-07-23)*
Projektskelett som kompilerar, inloggning mot API:t med sessions- Projektskelett som kompilerar, inloggning mot API:t med sessions-
återställning vid appstart, hemskärm som visar profilen. återställning vid appstart, hemskärm som visar profilen.
2. **Aktivt pass** — starta/återuppta pass, lägga till övningar, logga set 2. **Aktivt pass** *(klar 2026-07-24)* passläge med en övning i taget
(reps/vikt/distans/tid/RPE), vilotimer, passtimer, avsluta pass. (svep), ±steppers och ett-trycks setloggning med förifyllda värden,
Fälten styrs av övningens tracks-flaggor precis som i webben. vilotimer (helskärm/banner + larm/vibration/visuell/inget — inställbart),
3. **Passhistorik** — lista, visa, redigera och ta bort tidigare pass. offline-först via Room + synk-kö med statusindikatorer, övningsväljare
med muskelfilter, bottenrad-navigation med start-FAB och minispelare.
API-tillägg: `MuscleGroup.iconKey`, `ExerciseType.defaultRestSeconds`.
*Polering 2026-07-24/25:* rätta/ta bort loggade set, passdetaljvy i
historiken, övningshistorik i passläget (tidigare pass + tyngsta 3 mån),
avsluta-överblick med redigerbar start/varaktighet (date/time-pickers,
hopfällt bakom "Ändra tid"), kcal-summering med "Hur räknas detta?"
(MET × kroppsvikt × timmar, även i historiken) och 🏆-markering av
nya personbästa per rep-antal (PB-cache i Room).
3. **Statistik + robust inloggning** *(klar 2026-07-25, version 0.3.0)*
statistikfliken med periodväljare (vecka/månad/halvår/år/totalt),
KPI-kort med delta mot förra perioden, scrollbar volymtrend,
kroppskarta (fram/bak, volymvärme per muskelgrupp), höjdpunkter och
PB-lista per övning. Tyst återinloggning: uppgifterna sparas
AES-krypterat via Android Keystore och appen loggar in igen automatiskt
när servern avvisat sessionen (aktiveras vid inloggning; toggle i
inställningarna). Utökade inställningar: viktsteg (1,25/2,5/5 kg),
API-url, auto-återinloggning, egna stångvikter.
*Dessutom:* **in-app-uppdatering** (CI publicerar version.json;
hemskärmskort laddar ner, sha256-verifierar och installerar),
**skivkalkylator** (tryck på vikten i aktiva setet; stång 20/0/egna,
skivor i standardfärger), **passlängd** i historiken, och
**kroppsviktspårning**: API-modellen BodyMeasurement (uuid, datum,
vikt + muskel/fett/vatten-% för framtiden), uppdatera/rätta/ta bort
mätningar från profilen med datumväljare, viktgraf i statistiken med
glidande medelvärde och linjär trendlinje. Profilens bodyWeightKg
speglar alltid senaste mätningen.
4. **BT-våg via openScale-drivrutiner** *(planerad)* — hämta vägningar
(vikt + %muskler/fett/vatten) direkt från en Bluetooth-våg genom att
bädda in openScales drivrutiner. Kräver relicens till GPL-3.0. Full plan:
[`doc/openscale-integration.md`](openscale-integration.md).
5. **Passhistorik** — lista, visa, redigera och ta bort tidigare pass.
4. **Logga lyft (snabbloggning) + PB** — enkel lyftlogg och PB-matrisen. 4. **Logga lyft (snabbloggning) + PB** — enkel lyftlogg och PB-matrisen.
5. **Statistik** — perioder (vecka/månad/halvår/år), KPI-kort, trenddiagram, 5. **Statistik** — perioder (vecka/månad/halvår/år), KPI-kort, trenddiagram,
muskelfördelning, aktivitets-heatmap. muskelfördelning, aktivitets-heatmap.

View File

@@ -9,6 +9,8 @@ navigationCompose = "2.8.5"
okhttp = "4.12.0" okhttp = "4.12.0"
kotlinxSerialization = "1.7.3" kotlinxSerialization = "1.7.3"
datastore = "1.1.1" datastore = "1.1.1"
room = "2.6.1"
ksp = "2.0.21-1.0.28"
[libraries] [libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
@@ -26,9 +28,13 @@ androidx-navigation-compose = { group = "androidx.navigation", name = "navigatio
androidx-datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastore" } androidx-datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastore" }
okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" } okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" }
kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinxSerialization" } kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinxSerialization" }
androidx-room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" }
androidx-room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "room" }
androidx-room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" }
[plugins] [plugins]
android-application = { id = "com.android.application", version.ref = "agp" } android-application = { id = "com.android.application", version.ref = "agp" }
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }