From 7a04893294807651bb6e03bd459be0988039a7e8 Mon Sep 17 00:00:00 2001 From: claude Date: Sat, 25 Jul 2026 13:03:24 +0200 Subject: [PATCH] =?UTF-8?q?M3:=20statistik,=20kroppskarta,=20PB-lista,=20t?= =?UTF-8?q?yst=20=C3=A5terinloggning,=20fler=20inst=C3=A4llningar?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 Claude-Session: https://claude.ai/code/session_01Kf4MAbZ3c3B4Xy5XfuGsCP --- app/build.gradle.kts | 2 +- .../fitnessdroid/FitnessDroidApplication.kt | 4 +- .../fitnessdroid/data/AuthRepository.kt | 39 ++- .../fitnessdroid/data/CredentialStore.kt | 85 +++++ .../fitnessdroid/data/GymRepository.kt | 114 +++++++ .../fitnessdroid/data/SettingsStore.kt | 16 + .../fitnessdroid/ui/session/SessionScreen.kt | 16 +- .../ui/settings/SettingsScreen.kt | 95 +++++- .../fitnessdroid/ui/stats/BodyHeatMap.kt | 133 ++++++++ .../fitnessdroid/ui/stats/StatsScreen.kt | 316 +++++++++++++++++- doc/plan.md | 10 +- 11 files changed, 799 insertions(+), 31 deletions(-) create mode 100644 app/src/main/java/eu/brassepc/fitnessdroid/data/CredentialStore.kt create mode 100644 app/src/main/java/eu/brassepc/fitnessdroid/ui/stats/BodyHeatMap.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 71b57e8..14ddeaa 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -29,7 +29,7 @@ android { minSdk = 31 targetSdk = 35 versionCode = ciRunNumber ?: 1 - versionName = "0.2.0" + (ciRunNumber?.let { "+build.$it" } ?: "-dev") + versionName = "0.3.0" + (ciRunNumber?.let { "+build.$it" } ?: "-dev") } signingConfigs { diff --git a/app/src/main/java/eu/brassepc/fitnessdroid/FitnessDroidApplication.kt b/app/src/main/java/eu/brassepc/fitnessdroid/FitnessDroidApplication.kt index 380309b..9e8d4de 100644 --- a/app/src/main/java/eu/brassepc/fitnessdroid/FitnessDroidApplication.kt +++ b/app/src/main/java/eu/brassepc/fitnessdroid/FitnessDroidApplication.kt @@ -3,6 +3,7 @@ package eu.brassepc.fitnessdroid import android.app.Application import android.content.Context import eu.brassepc.fitnessdroid.data.AuthRepository +import eu.brassepc.fitnessdroid.data.CredentialStore import eu.brassepc.fitnessdroid.data.GraphQlClient import eu.brassepc.fitnessdroid.data.GymApi import eu.brassepc.fitnessdroid.data.GymRepository @@ -20,8 +21,9 @@ class AppContainer(context: Context) { val appScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) val tokenStore = TokenStore(context) val settingsStore = SettingsStore(context) + val credentialStore = CredentialStore(context) val graphQlClient = GraphQlClient { tokenStore.apiUrl() } - val authRepository = AuthRepository(tokenStore, graphQlClient) + val authRepository = AuthRepository(tokenStore, graphQlClient, credentialStore, settingsStore) val gymApi = GymApi(graphQlClient, authRepository) val database = AppDatabase.build(context) val syncEngine = SyncEngine(database, graphQlClient, authRepository, appScope) diff --git a/app/src/main/java/eu/brassepc/fitnessdroid/data/AuthRepository.kt b/app/src/main/java/eu/brassepc/fitnessdroid/data/AuthRepository.kt index 2511c68..f5a52e8 100644 --- a/app/src/main/java/eu/brassepc/fitnessdroid/data/AuthRepository.kt +++ b/app/src/main/java/eu/brassepc/fitnessdroid/data/AuthRepository.kt @@ -2,6 +2,7 @@ package eu.brassepc.fitnessdroid.data import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.first import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.serialization.json.booleanOrNull @@ -26,6 +27,8 @@ sealed interface AuthState { class AuthRepository( private val store: TokenStore, private val client: GraphQlClient, + private val credentials: CredentialStore, + private val settings: SettingsStore, ) { private val _state = MutableStateFlow(AuthState.Restoring) val state: StateFlow = _state @@ -59,9 +62,32 @@ class AuthRepository( refreshToken = refreshToken, 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) } + /** + * 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(). */ suspend fun restoreSession() { val saved = store.read() @@ -78,6 +104,8 @@ class AuthRepository( ?.get("isValid")?.jsonPrimitive?.booleanOrNull == true if (valid) { _state.value = AuthState.LoggedIn(saved.username.orEmpty()) + } else if (trySilentRelogin() != null) { + // login() har redan satt LoggedIn — utloggningen märktes aldrig. } else { store.clearSession() _state.value = AuthState.LoggedOut @@ -110,6 +138,7 @@ class AuthRepository( val sessionId = saved.sessionId val refreshToken = saved.refreshToken if (sessionId == null || refreshToken == null) { + trySilentRelogin()?.let { return it } forceLogout() throw GraphQlException(listOf("Sessionen har gått ut, logga in igen")) } @@ -127,15 +156,17 @@ class AuthRepository( val newRefresh = result?.get("newRefreshToken")?.jsonPrimitive?.contentOrNullSafe() val newExpiration = result?.get("accessTokenExpiration")?.jsonPrimitive?.contentOrNullSafe() if (!success || newToken == null || newRefresh == null) { + trySilentRelogin()?.let { return it } forceLogout() throw GraphQlException(listOf("Sessionen har gått ut, logga in igen")) } store.updateTokens(newToken, newRefresh, parseExpiration(newExpiration)) newToken } catch (e: GraphQlException) { - // Servern avvisade refresh-tokenen — riktig utloggning. - // Nätfel (IOException) bubblar istället uppåt så att synkmotorn - // kan försöka igen utan att kasta ut användaren. + // 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() throw GraphQlException(listOf("Sessionen har gått ut, logga in igen")) } @@ -148,6 +179,8 @@ class AuthRepository( client.execute(LOGOUT, buildJsonObject { put("s", saved.sessionId) }) } } + // Manuell utloggning = släng även de sparade uppgifterna. + runCatching { credentials.clear() } forceLogout() } diff --git a/app/src/main/java/eu/brassepc/fitnessdroid/data/CredentialStore.kt b/app/src/main/java/eu/brassepc/fitnessdroid/data/CredentialStore.kt new file mode 100644 index 0000000..feba15c --- /dev/null +++ b/app/src/main/java/eu/brassepc/fitnessdroid/data/CredentialStore.kt @@ -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") + } +} diff --git a/app/src/main/java/eu/brassepc/fitnessdroid/data/GymRepository.kt b/app/src/main/java/eu/brassepc/fitnessdroid/data/GymRepository.kt index bd4c3a8..60aabde 100644 --- a/app/src/main/java/eu/brassepc/fitnessdroid/data/GymRepository.kt +++ b/app/src/main/java/eu/brassepc/fitnessdroid/data/GymRepository.kt @@ -501,6 +501,95 @@ class GymRepository( ) } + /** 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 = 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 = 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( @@ -621,5 +710,30 @@ data class ExerciseStats( val metValue: 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) + +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, + val heaviestLift: String?, + val newPrCount: Int, + /** muskelgruppnamn → volym kg (för kroppskartan) */ + val muscleVolumes: Map, + val personalBests: List, +) + private fun kotlinx.serialization.json.JsonPrimitive.contentOrNull(): String? = if (this is kotlinx.serialization.json.JsonNull) null else content diff --git a/app/src/main/java/eu/brassepc/fitnessdroid/data/SettingsStore.kt b/app/src/main/java/eu/brassepc/fitnessdroid/data/SettingsStore.kt index 80cf1ce..36a6b8f 100644 --- a/app/src/main/java/eu/brassepc/fitnessdroid/data/SettingsStore.kt +++ b/app/src/main/java/eu/brassepc/fitnessdroid/data/SettingsStore.kt @@ -17,6 +17,10 @@ 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, ) class SettingsStore(private val context: Context) { @@ -30,9 +34,19 @@ class SettingsStore(private val context: Context) { 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, ) } + 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 } } @@ -49,5 +63,7 @@ class SettingsStore(private val context: Context) { 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") } } diff --git a/app/src/main/java/eu/brassepc/fitnessdroid/ui/session/SessionScreen.kt b/app/src/main/java/eu/brassepc/fitnessdroid/ui/session/SessionScreen.kt index ee95e0c..e25bb1b 100644 --- a/app/src/main/java/eu/brassepc/fitnessdroid/ui/session/SessionScreen.kt +++ b/app/src/main/java/eu/brassepc/fitnessdroid/ui/session/SessionScreen.kt @@ -141,6 +141,7 @@ fun SessionScreen( page = uiState.pages[pageIndex], pageIndex = pageIndex, pageCount = uiState.pages.size, + weightStep = uiState.settings.weightStep, onDraft = viewModel::updateDraft, onLog = viewModel::logSet, onEditSet = { page, set -> editTarget = page to set }, @@ -178,6 +179,7 @@ fun SessionScreen( EditSetDialog( page = page, set = set, + weightStep = uiState.settings.weightStep, onSave = { draft -> viewModel.saveEditedSet(set.id, draft) editTarget = null @@ -259,6 +261,7 @@ private fun ExercisePageContent( page: ExercisePage, pageIndex: Int, pageCount: Int, + weightStep: Double, onDraft: (Long, (SetDraft) -> SetDraft) -> Unit, onLog: (Long) -> Unit, onEditSet: (ExercisePage, LocalSet) -> Unit, @@ -307,10 +310,10 @@ private fun ExercisePageContent( if (page.type?.tracksWeight != false) { Stepper( - label = "kg", + label = "kg · ±${weightStep.compact()}", value = page.draft.weight?.compact() ?: "—", - onMinus = { onDraft(page.localId) { d -> d.copy(weight = ((d.weight ?: 0.0) - 2.5).coerceAtLeast(0.0)) } }, - onPlus = { onDraft(page.localId) { d -> d.copy(weight = (d.weight ?: 0.0) + 2.5) } }, + onMinus = { onDraft(page.localId) { d -> d.copy(weight = ((d.weight ?: 0.0) - weightStep).coerceAtLeast(0.0)) } }, + onPlus = { onDraft(page.localId) { d -> d.copy(weight = (d.weight ?: 0.0) + weightStep) } }, ) } if (page.type?.tracksReps != false) { @@ -953,6 +956,7 @@ private fun ExerciseHistorySheet( private fun EditSetDialog( page: ExercisePage, set: LocalSet, + weightStep: Double, onSave: (SetDraft) -> Unit, onDelete: () -> Unit, onDismiss: () -> Unit, @@ -976,10 +980,10 @@ private fun EditSetDialog( Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { if (page.type?.tracksWeight != false) { Stepper( - label = "kg", + label = "kg · ±${weightStep.compact()}", value = draft.weight?.compact() ?: "—", - onMinus = { draft = draft.copy(weight = ((draft.weight ?: 0.0) - 2.5).coerceAtLeast(0.0)) }, - onPlus = { draft = draft.copy(weight = (draft.weight ?: 0.0) + 2.5) }, + onMinus = { draft = draft.copy(weight = ((draft.weight ?: 0.0) - weightStep).coerceAtLeast(0.0)) }, + onPlus = { draft = draft.copy(weight = (draft.weight ?: 0.0) + weightStep) }, ) } if (page.type?.tracksReps != false) { diff --git a/app/src/main/java/eu/brassepc/fitnessdroid/ui/settings/SettingsScreen.kt b/app/src/main/java/eu/brassepc/fitnessdroid/ui/settings/SettingsScreen.kt index 9310531..59ead04 100644 --- a/app/src/main/java/eu/brassepc/fitnessdroid/ui/settings/SettingsScreen.kt +++ b/app/src/main/java/eu/brassepc/fitnessdroid/ui/settings/SettingsScreen.kt @@ -43,19 +43,46 @@ import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch -class SettingsViewModel(private val store: SettingsStore) : ViewModel() { +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 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 { SettingsViewModel(appContainer().settingsStore) } + initializer { + val c = appContainer() + SettingsViewModel(c.settingsStore, c.tokenStore, c.credentialStore) + } } } } @@ -130,6 +157,70 @@ fun SettingsScreen( ) } + 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 = "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", + 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 diff --git a/app/src/main/java/eu/brassepc/fitnessdroid/ui/stats/BodyHeatMap.kt b/app/src/main/java/eu/brassepc/fitnessdroid/ui/stats/BodyHeatMap.kt new file mode 100644 index 0000000..2f6437e --- /dev/null +++ b/app/src/main/java/eu/brassepc/fitnessdroid/ui/stats/BodyHeatMap.kt @@ -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 { + 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, + baseColor: Color, + hotColor: Color, + modifier: Modifier = Modifier, +) { + val zoneHeat = HashMap() + 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)) +} diff --git a/app/src/main/java/eu/brassepc/fitnessdroid/ui/stats/StatsScreen.kt b/app/src/main/java/eu/brassepc/fitnessdroid/ui/stats/StatsScreen.kt index 9b9b86a..1a14890 100644 --- a/app/src/main/java/eu/brassepc/fitnessdroid/ui/stats/StatsScreen.kt +++ b/app/src/main/java/eu/brassepc/fitnessdroid/ui/stats/StatsScreen.kt @@ -1,32 +1,314 @@ 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.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.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.text.style.TextAlign +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 -@Composable -fun StatsScreen() { - Column( - modifier = Modifier.fillMaxSize().padding(32.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center, - ) { - Text("Statistik", style = MaterialTheme.typography.headlineSmall) - Text( - "Kroppskartan, trender och PB kommer i nästa milstolpe. " + - "Din data samlas redan — allt du loggar syns här sen.", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - textAlign = TextAlign.Center, - modifier = Modifier.padding(top = 8.dp), - ) +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"), +) + +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.Loading) + + init { load() } + + 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() + + 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 { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + PERIODS.forEach { 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 ${if (period == "week") "dag" else "period"}", 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), + ) + } + } + } + } + } + + 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 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 + Row( + modifier = Modifier + .fillMaxWidth() + .height(96.dp) + .padding(top = 10.dp), + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.Bottom, + ) { + stats.trend.forEach { bucket -> + Column( + modifier = Modifier.weight(1f), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(((bucket.volumeKg / max) * 72).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.takeLast(5), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + ) + } + } } } diff --git a/doc/plan.md b/doc/plan.md index a76eb05..49af895 100644 --- a/doc/plan.md +++ b/doc/plan.md @@ -43,7 +43,15 @@ mallar, grunddata och profil. 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. **Passhistorik** — lista, visa, redigera och ta bort tidigare pass. +3. **Statistik + robust inloggning** *(påbörjad 2026-07-25, version 0.3.0)* — + statistikfliken med periodväljare (vecka/månad/halvår/år), KPI-kort med + delta mot förra perioden, volymtrend-staplar, 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 (toggle i inställningarna). Utökade inställningar: viktsteg + (1,25/2,5/5 kg), API-url, auto-återinloggning. +4. **Passhistorik** — lista, visa, redigera och ta bort tidigare pass. 4. **Logga lyft (snabbloggning) + PB** — enkel lyftlogg och PB-matrisen. 5. **Statistik** — perioder (vecka/månad/halvår/år), KPI-kort, trenddiagram, muskelfördelning, aktivitets-heatmap.