M3: statistik, kroppskarta, PB-lista, tyst återinloggning, fler inställningar
Some checks failed
release / build-release (push) Has been cancelled
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:
@@ -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 {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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))
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|
||||||
@Composable
|
data class PeriodOption(val id: String, val label: String)
|
||||||
fun StatsScreen() {
|
|
||||||
Column(
|
val PERIODS = listOf(
|
||||||
modifier = Modifier.fillMaxSize().padding(32.dp),
|
PeriodOption("week", "Vecka"),
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
PeriodOption("month", "Månad"),
|
||||||
verticalArrangement = Arrangement.Center,
|
PeriodOption("halfyear", "Halvår"),
|
||||||
) {
|
PeriodOption("year", "År"),
|
||||||
Text("Statistik", style = MaterialTheme.typography.headlineSmall)
|
)
|
||||||
Text(
|
|
||||||
"Kroppskartan, trender och PB kommer i nästa milstolpe. " +
|
sealed interface StatsUiState {
|
||||||
"Din data samlas redan — allt du loggar syns här sen.",
|
data object Loading : StatsUiState
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
data class Error(val message: String) : StatsUiState
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
data class Ready(val stats: StatsBundle) : StatsUiState
|
||||||
textAlign = TextAlign.Center,
|
}
|
||||||
modifier = Modifier.padding(top = 8.dp),
|
|
||||||
)
|
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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
10
doc/plan.md
10
doc/plan.md
@@ -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.
|
||||||
|
|||||||
Reference in New Issue
Block a user