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
targetSdk = 35
versionCode = ciRunNumber ?: 1
versionName = "0.2.0" + (ciRunNumber?.let { "+build.$it" } ?: "-dev")
versionName = "0.3.0" + (ciRunNumber?.let { "+build.$it" } ?: "-dev")
}
signingConfigs {

View File

@@ -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)

View File

@@ -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>(AuthState.Restoring)
val state: StateFlow<AuthState> = _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()
}

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. */
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<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

@@ -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")
}
}

View File

@@ -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) {

View File

@@ -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

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
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>(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,
)
}
}
}
}

View File

@@ -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.