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
|
||||
targetSdk = 35
|
||||
versionCode = ciRunNumber ?: 1
|
||||
versionName = "0.2.0" + (ciRunNumber?.let { "+build.$it" } ?: "-dev")
|
||||
versionName = "0.3.0" + (ciRunNumber?.let { "+build.$it" } ?: "-dev")
|
||||
}
|
||||
|
||||
signingConfigs {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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. */
|
||||
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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user