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
This commit is contained in:
2026-07-25 13:03:24 +02:00
parent 31d05039f0
commit 7a04893294
11 changed files with 799 additions and 31 deletions

View File

@@ -29,7 +29,7 @@ android {
minSdk = 31 minSdk = 31
targetSdk = 35 targetSdk = 35
versionCode = ciRunNumber ?: 1 versionCode = ciRunNumber ?: 1
versionName = "0.2.0" + (ciRunNumber?.let { "+build.$it" } ?: "-dev") versionName = "0.3.0" + (ciRunNumber?.let { "+build.$it" } ?: "-dev")
} }
signingConfigs { signingConfigs {

View File

@@ -3,6 +3,7 @@ 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.GymRepository
@@ -20,8 +21,9 @@ class AppContainer(context: Context) {
val appScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) val appScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
val tokenStore = TokenStore(context) val tokenStore = TokenStore(context)
val settingsStore = SettingsStore(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 database = AppDatabase.build(context)
val syncEngine = SyncEngine(database, graphQlClient, authRepository, appScope) val syncEngine = SyncEngine(database, graphQlClient, authRepository, appScope)

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,9 +62,32 @@ 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()
@@ -78,6 +104,8 @@ 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
@@ -110,6 +138,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,15 +156,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 — riktig utloggning. // Servern avvisade refresh-tokenen. Försök logga in tyst igen med
// Nätfel (IOException) bubblar istället uppåt så att synkmotorn // sparade uppgifter innan riktig utloggning. Nätfel (IOException)
// kan försöka igen utan att kasta ut användaren. // 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"))
} }
@@ -148,6 +179,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

@@ -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<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. */ /** Serverns kaloriberäkning och tidsfördelning för ett pass. */
suspend fun fetchSessionStats(id: Int): SessionStats { suspend fun fetchSessionStats(id: Int): SessionStats {
val data = client.execute( val data = client.execute(
@@ -621,5 +710,30 @@ data class ExerciseStats(
val metValue: Double?, 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<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? = private fun kotlinx.serialization.json.JsonPrimitive.contentOrNull(): String? =
if (this is kotlinx.serialization.json.JsonNull) null else content if (this is kotlinx.serialization.json.JsonNull) null else content

View File

@@ -17,6 +17,10 @@ data class AppSettings(
val restTimerStyle: RestTimerStyle = RestTimerStyle.FULLSCREEN, val restTimerStyle: RestTimerStyle = RestTimerStyle.FULLSCREEN,
val restAlert: RestAlert = RestAlert.VIBRATE, val restAlert: RestAlert = RestAlert.VIBRATE,
val defaultRestSeconds: Int = 90, 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) { class SettingsStore(private val context: Context) {
@@ -30,9 +34,19 @@ class SettingsStore(private val context: Context) {
runCatching { RestAlert.valueOf(it) }.getOrNull() runCatching { RestAlert.valueOf(it) }.getOrNull()
} ?: RestAlert.VIBRATE, } ?: RestAlert.VIBRATE,
defaultRestSeconds = prefs[KEY_REST_SECONDS] ?: 90, 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) { suspend fun setRestTimerStyle(value: RestTimerStyle) {
context.settingsDataStore.edit { it[KEY_TIMER_STYLE] = value.name } 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_TIMER_STYLE = stringPreferencesKey("rest_timer_style")
private val KEY_REST_ALERT = stringPreferencesKey("rest_alert") private val KEY_REST_ALERT = stringPreferencesKey("rest_alert")
private val KEY_REST_SECONDS = intPreferencesKey("default_rest_seconds") 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")
} }
} }

View File

@@ -141,6 +141,7 @@ fun SessionScreen(
page = uiState.pages[pageIndex], page = uiState.pages[pageIndex],
pageIndex = pageIndex, pageIndex = pageIndex,
pageCount = uiState.pages.size, pageCount = uiState.pages.size,
weightStep = uiState.settings.weightStep,
onDraft = viewModel::updateDraft, onDraft = viewModel::updateDraft,
onLog = viewModel::logSet, onLog = viewModel::logSet,
onEditSet = { page, set -> editTarget = page to set }, onEditSet = { page, set -> editTarget = page to set },
@@ -178,6 +179,7 @@ fun SessionScreen(
EditSetDialog( EditSetDialog(
page = page, page = page,
set = set, set = set,
weightStep = uiState.settings.weightStep,
onSave = { draft -> onSave = { draft ->
viewModel.saveEditedSet(set.id, draft) viewModel.saveEditedSet(set.id, draft)
editTarget = null editTarget = null
@@ -259,6 +261,7 @@ private fun ExercisePageContent(
page: ExercisePage, page: ExercisePage,
pageIndex: Int, pageIndex: Int,
pageCount: Int, pageCount: Int,
weightStep: Double,
onDraft: (Long, (SetDraft) -> SetDraft) -> Unit, onDraft: (Long, (SetDraft) -> SetDraft) -> Unit,
onLog: (Long) -> Unit, onLog: (Long) -> Unit,
onEditSet: (ExercisePage, LocalSet) -> Unit, onEditSet: (ExercisePage, LocalSet) -> Unit,
@@ -307,10 +310,10 @@ private fun ExercisePageContent(
if (page.type?.tracksWeight != false) { if (page.type?.tracksWeight != false) {
Stepper( Stepper(
label = "kg", label = "kg · ±${weightStep.compact()}",
value = page.draft.weight?.compact() ?: "", value = page.draft.weight?.compact() ?: "",
onMinus = { onDraft(page.localId) { d -> d.copy(weight = ((d.weight ?: 0.0) - 2.5).coerceAtLeast(0.0)) } }, 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) + 2.5) } }, onPlus = { onDraft(page.localId) { d -> d.copy(weight = (d.weight ?: 0.0) + weightStep) } },
) )
} }
if (page.type?.tracksReps != false) { if (page.type?.tracksReps != false) {
@@ -953,6 +956,7 @@ private fun ExerciseHistorySheet(
private fun EditSetDialog( private fun EditSetDialog(
page: ExercisePage, page: ExercisePage,
set: LocalSet, set: LocalSet,
weightStep: Double,
onSave: (SetDraft) -> Unit, onSave: (SetDraft) -> Unit,
onDelete: () -> Unit, onDelete: () -> Unit,
onDismiss: () -> Unit, onDismiss: () -> Unit,
@@ -976,10 +980,10 @@ private fun EditSetDialog(
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
if (page.type?.tracksWeight != false) { if (page.type?.tracksWeight != false) {
Stepper( Stepper(
label = "kg", label = "kg · ±${weightStep.compact()}",
value = draft.weight?.compact() ?: "", value = draft.weight?.compact() ?: "",
onMinus = { draft = draft.copy(weight = ((draft.weight ?: 0.0) - 2.5).coerceAtLeast(0.0)) }, onMinus = { draft = draft.copy(weight = ((draft.weight ?: 0.0) - weightStep).coerceAtLeast(0.0)) },
onPlus = { draft = draft.copy(weight = (draft.weight ?: 0.0) + 2.5) }, onPlus = { draft = draft.copy(weight = (draft.weight ?: 0.0) + weightStep) },
) )
} }
if (page.type?.tracksReps != false) { if (page.type?.tracksReps != false) {

View File

@@ -43,19 +43,46 @@ import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch 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 val settings = store.settings
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), AppSettings()) .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 setStyle(value: RestTimerStyle) = viewModelScope.launch { store.setRestTimerStyle(value) }
fun setAlert(value: RestAlert) = viewModelScope.launch { store.setRestAlert(value) } fun setAlert(value: RestAlert) = viewModelScope.launch { store.setRestAlert(value) }
fun adjustRestSeconds(delta: Int) = viewModelScope.launch { fun adjustRestSeconds(delta: Int) = viewModelScope.launch {
store.setDefaultRestSeconds(settings.value.defaultRestSeconds + delta) 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 { companion object {
val Factory: ViewModelProvider.Factory = viewModelFactory { 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") { SettingsCard(title = "Standardvila mellan set") {
Row( Row(
modifier = Modifier modifier = Modifier

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

@@ -1,32 +1,314 @@
package eu.brassepc.fitnessdroid.ui.stats package eu.brassepc.fitnessdroid.ui.stats
import androidx.compose.foundation.background
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.fillMaxSize 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.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.MaterialTheme
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.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier 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.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"),
)
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)
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 @Composable
fun StatsScreen() { fun StatsScreen(viewModel: StatsViewModel = viewModel(factory = StatsViewModel.Factory)) {
Column( val uiState by viewModel.uiState.collectAsStateWithLifecycle()
modifier = Modifier.fillMaxSize().padding(32.dp), val period by viewModel.period.collectAsStateWithLifecycle()
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center, LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = androidx.compose.foundation.layout.PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) { ) {
item {
Text("Statistik", style = MaterialTheme.typography.headlineSmall) 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( Text(
"Kroppskartan, trender och PB kommer i nästa milstolpe. " + "Ingen loggad volym i perioden.",
"Din data samlas redan — allt du loggar syns här sen.", style = MaterialTheme.typography.bodySmall,
style = MaterialTheme.typography.bodyMedium, 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, color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
modifier = Modifier.padding(top = 8.dp), 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,
)
}
}
}
}

View File

@@ -43,7 +43,15 @@ mallar, grunddata och profil.
hopfällt bakom "Ändra tid"), kcal-summering med "Hur räknas detta?" hopfällt bakom "Ändra tid"), kcal-summering med "Hur räknas detta?"
(MET × kroppsvikt × timmar, även i historiken) och 🏆-markering av (MET × kroppsvikt × timmar, även i historiken) och 🏆-markering av
nya personbästa per rep-antal (PB-cache i Room). 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. 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.