Compare commits
25 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| efebcb111f | |||
| a99c11524b | |||
| b33a849f23 | |||
| 7c21428a28 | |||
| 7a4995823e | |||
| 143abee871 | |||
| e2ffb4f011 | |||
| d577a92eec | |||
| bb3502ed26 | |||
| 34c2a6c4da | |||
| df4d122562 | |||
| 7a04893294 | |||
| 31d05039f0 | |||
| 0812759719 | |||
| 493baee88d | |||
| 27331b20a7 | |||
| 983886e8f1 | |||
| 8928a4edb4 | |||
| 214e12d2e2 | |||
| 68dd0135ab | |||
| 0f62741043 | |||
| 93a1aa249a | |||
| 2c17873ca4 | |||
| 984e2374f8 | |||
| 53d17d467f |
@@ -90,6 +90,18 @@ jobs:
|
||||
mkdir -p build
|
||||
cp app/build/outputs/apk/release/app-release.apk build/FitnessDroid.apk
|
||||
(cd build && sha256sum FitnessDroid.apk > checksums.txt && ls -la)
|
||||
# version.json för appens inbyggda uppdateringskoll
|
||||
VN=$("$HOME/ci-tools/build-tools/aapt2" dump badging build/FitnessDroid.apk \
|
||||
| sed -n "s/.*versionName='\([^']*\)'.*/\1/p" | head -1)
|
||||
SHA=$(cut -d' ' -f1 build/checksums.txt)
|
||||
jq -n \
|
||||
--argjson code "${{ github.run_number }}" \
|
||||
--arg name "$VN" \
|
||||
--arg sha "$SHA" \
|
||||
--arg url "https://gitea.brasse-pc.eu/${{ github.repository }}/releases/download/latest/FitnessDroid.apk" \
|
||||
'{versionCode:$code, versionName:$name, sha256:$sha, apkUrl:$url}' \
|
||||
> build/version.json
|
||||
cat build/version.json
|
||||
|
||||
- name: Skapa/uppdatera latest-release + ladda upp APK
|
||||
env:
|
||||
@@ -124,7 +136,7 @@ jobs:
|
||||
-d "$(jq -n --arg body "$BODY" '{body:$body}')" -o /dev/null
|
||||
fi
|
||||
echo "release id: $rid"
|
||||
for f in FitnessDroid.apk checksums.txt; do
|
||||
for f in FitnessDroid.apk checksums.txt version.json; do
|
||||
aid=$(curl -s "$API/repos/$REPO/releases/$rid/assets" \
|
||||
-H "Authorization: token $TOKEN" | jq -r ".[] | select(.name==\"$f\") | .id")
|
||||
if [ -n "$aid" ] && [ "$aid" != "null" ]; then
|
||||
|
||||
@@ -5,6 +5,7 @@ plugins {
|
||||
alias(libs.plugins.kotlin.android)
|
||||
alias(libs.plugins.kotlin.compose)
|
||||
alias(libs.plugins.kotlin.serialization)
|
||||
alias(libs.plugins.ksp)
|
||||
}
|
||||
|
||||
// Signeringsnyckeln är inte incheckad. Lokalt ligger den i signing/ (gitignorerad),
|
||||
@@ -28,7 +29,7 @@ android {
|
||||
minSdk = 31
|
||||
targetSdk = 35
|
||||
versionCode = ciRunNumber ?: 1
|
||||
versionName = "0.1.0" + (ciRunNumber?.let { "+build.$it" } ?: "-dev")
|
||||
versionName = "0.3.2" + (ciRunNumber?.let { "+build.$it" } ?: "-dev")
|
||||
}
|
||||
|
||||
signingConfigs {
|
||||
@@ -80,5 +81,8 @@ dependencies {
|
||||
implementation(libs.androidx.datastore.preferences)
|
||||
implementation(libs.okhttp)
|
||||
implementation(libs.kotlinx.serialization.json)
|
||||
implementation(libs.androidx.room.runtime)
|
||||
implementation(libs.androidx.room.ktx)
|
||||
ksp(libs.androidx.room.compiler)
|
||||
debugImplementation(libs.androidx.compose.ui.tooling)
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.VIBRATE" />
|
||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
||||
|
||||
<application
|
||||
android:name=".FitnessDroidApplication"
|
||||
@@ -10,6 +12,16 @@
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.FitnessDroid">
|
||||
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
android:authorities="${applicationId}.fileprovider"
|
||||
android:exported="false"
|
||||
android:grantUriPermissions="true">
|
||||
<meta-data
|
||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||
android:resource="@xml/file_paths" />
|
||||
</provider>
|
||||
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
|
||||
@@ -3,16 +3,34 @@ 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
|
||||
import eu.brassepc.fitnessdroid.data.RestTimerController
|
||||
import eu.brassepc.fitnessdroid.data.SettingsStore
|
||||
import eu.brassepc.fitnessdroid.data.SyncEngine
|
||||
import eu.brassepc.fitnessdroid.data.TokenStore
|
||||
import eu.brassepc.fitnessdroid.data.UpdateChecker
|
||||
import eu.brassepc.fitnessdroid.data.local.AppDatabase
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
|
||||
/** Enkel manuell DI-container — appen är för liten för Hilt. */
|
||||
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)
|
||||
val gymRepository = GymRepository(database, graphQlClient, authRepository, syncEngine, tokenStore, appScope)
|
||||
val restTimer = RestTimerController(context, settingsStore, appScope)
|
||||
val updateChecker = UpdateChecker(context)
|
||||
}
|
||||
|
||||
class FitnessDroidApplication : Application() {
|
||||
|
||||
@@ -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,14 +62,40 @@ 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()
|
||||
if (saved.sessionId.isNullOrEmpty() || saved.refreshToken.isNullOrEmpty()) {
|
||||
_state.value = AuthState.LoggedOut
|
||||
// Inga tokens alls — men kanske sparade uppgifter (t.ex. efter
|
||||
// att servern rensat sessioner). Prova tyst innan login-skärmen.
|
||||
val relogged = runCatching { trySilentRelogin() }.getOrNull()
|
||||
if (relogged == null) _state.value = AuthState.LoggedOut
|
||||
return
|
||||
}
|
||||
try {
|
||||
@@ -78,18 +107,22 @@ 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
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
// Nätverksfel vid uppstart: släpp in användaren om JWT:n inte hunnit
|
||||
// gå ut, annars visa inloggningen igen.
|
||||
// gå ut. Har den gått ut: prova tyst återinloggning (kan också
|
||||
// faila på nätet — då login-skärmen, synken tar det sen).
|
||||
val stillValid = (saved.expirationEpochMs ?: 0) > System.currentTimeMillis()
|
||||
_state.value = if (stillValid) {
|
||||
AuthState.LoggedIn(saved.username.orEmpty())
|
||||
} else {
|
||||
AuthState.LoggedOut
|
||||
_state.value = when {
|
||||
stillValid -> AuthState.LoggedIn(saved.username.orEmpty())
|
||||
runCatching { trySilentRelogin() }.getOrNull() != null ->
|
||||
AuthState.LoggedIn(store.read().username.orEmpty())
|
||||
else -> AuthState.LoggedOut
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -110,6 +143,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,12 +161,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. 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"))
|
||||
}
|
||||
@@ -145,6 +184,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")
|
||||
}
|
||||
}
|
||||
@@ -47,6 +47,11 @@ class GraphQlClient(private val urlProvider: suspend () -> String) {
|
||||
|
||||
http.newCall(request).execute().use { response ->
|
||||
val text = response.body?.string().orEmpty()
|
||||
// 5xx = servern/proxyn nere — behandla som nätfel (retry) och inte
|
||||
// som ett logiskt API-fel (som t.ex. loggar ut användaren).
|
||||
if (response.code >= 500) {
|
||||
throw java.io.IOException("HTTP ${response.code} från servern")
|
||||
}
|
||||
val root = runCatching { json.parseToJsonElement(text).jsonObject }.getOrNull()
|
||||
?: throw GraphQlException(listOf("HTTP ${response.code}: oväntat svar från servern"))
|
||||
|
||||
|
||||
857
app/src/main/java/eu/brassepc/fitnessdroid/data/GymRepository.kt
Normal file
857
app/src/main/java/eu/brassepc/fitnessdroid/data/GymRepository.kt
Normal file
@@ -0,0 +1,857 @@
|
||||
package eu.brassepc.fitnessdroid.data
|
||||
|
||||
import eu.brassepc.fitnessdroid.data.local.AppDatabase
|
||||
import eu.brassepc.fitnessdroid.data.local.CachedExerciseType
|
||||
import eu.brassepc.fitnessdroid.data.local.CachedMuscle
|
||||
import eu.brassepc.fitnessdroid.data.local.CachedMuscleGroup
|
||||
import eu.brassepc.fitnessdroid.data.local.CachedStartCard
|
||||
import eu.brassepc.fitnessdroid.data.local.LocalExercise
|
||||
import eu.brassepc.fitnessdroid.data.local.LocalSession
|
||||
import eu.brassepc.fitnessdroid.data.local.LocalSet
|
||||
import eu.brassepc.fitnessdroid.data.local.PendingOp
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.serialization.json.booleanOrNull
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.doubleOrNull
|
||||
import kotlinx.serialization.json.int
|
||||
import kotlinx.serialization.json.intOrNull
|
||||
import kotlinx.serialization.json.jsonArray
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import kotlinx.serialization.json.put
|
||||
|
||||
/**
|
||||
* Lokal-först-repository. Allt UI läser Room-flows; alla skrivningar sker
|
||||
* lokalt först och läggs på op-kön som [SyncEngine] driver mot servern.
|
||||
*/
|
||||
class GymRepository(
|
||||
private val db: AppDatabase,
|
||||
private val client: GraphQlClient,
|
||||
private val auth: AuthRepository,
|
||||
private val sync: SyncEngine,
|
||||
private val store: TokenStore,
|
||||
private val scope: CoroutineScope,
|
||||
) {
|
||||
val activeSession = db.sessionDao().activeSession()
|
||||
val exerciseTypes = db.cacheDao().exerciseTypes()
|
||||
val muscleGroups = db.cacheDao().muscleGroups()
|
||||
val muscles = db.cacheDao().muscles()
|
||||
val startCards = db.cacheDao().startCards()
|
||||
val pendingCount = db.opDao().pendingCount()
|
||||
val syncStatus = sync.status
|
||||
|
||||
fun exercises(sessionId: Long) = db.sessionDao().exercises(sessionId)
|
||||
fun setsForSession(sessionId: Long) = db.sessionDao().setsForSession(sessionId)
|
||||
|
||||
private suspend fun enqueue(kind: String, targetLocalId: Long) {
|
||||
db.opDao().enqueue(
|
||||
PendingOp(kind = kind, targetLocalId = targetLocalId, createdAtEpochMs = System.currentTimeMillis())
|
||||
)
|
||||
sync.kick()
|
||||
}
|
||||
|
||||
/* ---------- Passhantering ---------- */
|
||||
|
||||
suspend fun startFreeSession(name: String? = null): Long {
|
||||
val id = db.sessionDao().insertSession(
|
||||
LocalSession(name = name, startedAtEpochMs = System.currentTimeMillis())
|
||||
)
|
||||
enqueue(OpKind.START_SESSION, id)
|
||||
return id
|
||||
}
|
||||
|
||||
/** Starta från favoritpass/mall-kort: skapar passet + alla övningar lokalt. */
|
||||
suspend fun startFromCard(card: CachedStartCard): Long {
|
||||
val sessionId = db.sessionDao().insertSession(
|
||||
LocalSession(name = card.title, startedAtEpochMs = System.currentTimeMillis())
|
||||
)
|
||||
enqueue(OpKind.START_SESSION, sessionId)
|
||||
card.exerciseTypeIds.split(",").mapNotNull { it.trim().toIntOrNull() }
|
||||
.forEachIndexed { index, typeId ->
|
||||
val exId = db.sessionDao().insertExercise(
|
||||
LocalExercise(sessionId = sessionId, exerciseTypeId = typeId, order = index + 1)
|
||||
)
|
||||
enqueue(OpKind.ADD_EXERCISE, exId)
|
||||
}
|
||||
return sessionId
|
||||
}
|
||||
|
||||
suspend fun addExercise(sessionId: Long, exerciseTypeId: Int): Long {
|
||||
val order = db.sessionDao().exerciseCount(sessionId) + 1
|
||||
val id = db.sessionDao().insertExercise(
|
||||
LocalExercise(sessionId = sessionId, exerciseTypeId = exerciseTypeId, order = order)
|
||||
)
|
||||
enqueue(OpKind.ADD_EXERCISE, id)
|
||||
refreshLastPerformance(exerciseTypeId)
|
||||
return id
|
||||
}
|
||||
|
||||
suspend fun logSet(
|
||||
exerciseId: Long,
|
||||
reps: Int?,
|
||||
weight: Double?,
|
||||
distanceMeters: Double?,
|
||||
durationSeconds: Int?,
|
||||
rpe: Double?,
|
||||
isWarmup: Boolean,
|
||||
): Long {
|
||||
val order = db.sessionDao().setCount(exerciseId) + 1
|
||||
val id = db.sessionDao().insertSet(
|
||||
LocalSet(
|
||||
exerciseId = exerciseId,
|
||||
order = order,
|
||||
reps = reps,
|
||||
weight = weight,
|
||||
distanceMeters = distanceMeters,
|
||||
durationSeconds = durationSeconds,
|
||||
rpe = rpe,
|
||||
isWarmup = isWarmup,
|
||||
loggedAtEpochMs = System.currentTimeMillis(),
|
||||
)
|
||||
)
|
||||
enqueue(OpKind.ADD_SET, id)
|
||||
// Uppdatera cachens "senaste prestation" för förifyllnad nästa gång.
|
||||
db.sessionDao().exercise(exerciseId)?.let { ex ->
|
||||
db.cacheDao().updateLastPerformance(ex.exerciseTypeId, weight, reps, durationSeconds, distanceMeters)
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
suspend fun updateSet(set: LocalSet) {
|
||||
db.sessionDao().updateSet(set)
|
||||
// Inte synkad än? Då bär den väntande ADD_SET-opsen de nya värdena
|
||||
// automatiskt (den läser raden när den körs). Annars: egen update-op.
|
||||
if (set.serverId != null) enqueue(OpKind.UPDATE_SET, set.id)
|
||||
}
|
||||
|
||||
suspend fun deleteSet(setId: Long) {
|
||||
val set = db.sessionDao().set(setId) ?: return
|
||||
db.sessionDao().deleteSet(setId)
|
||||
// Osynkat set: den väntande ADD-opsen blir en no-op när raden är borta.
|
||||
// Synkat set: ta bort på servern också (server-id:t i op:en, raden finns inte kvar).
|
||||
set.serverId?.let { serverId ->
|
||||
db.opDao().enqueue(
|
||||
PendingOp(
|
||||
kind = OpKind.REMOVE_SET,
|
||||
targetLocalId = serverId.toLong(),
|
||||
createdAtEpochMs = System.currentTimeMillis(),
|
||||
)
|
||||
)
|
||||
sync.kick()
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun completeSession(
|
||||
sessionId: Long,
|
||||
durationSecondsOverride: Int? = null,
|
||||
editedStartEpochMs: Long? = null,
|
||||
) {
|
||||
val session = db.sessionDao().session(sessionId) ?: return
|
||||
db.sessionDao().updateSession(
|
||||
session.copy(
|
||||
status = "completed",
|
||||
durationSecondsOverride = durationSecondsOverride,
|
||||
editedStartEpochMs = editedStartEpochMs,
|
||||
startedAtEpochMs = editedStartEpochMs ?: session.startedAtEpochMs,
|
||||
)
|
||||
)
|
||||
enqueue(OpKind.COMPLETE_SESSION, sessionId)
|
||||
}
|
||||
|
||||
suspend fun bodyWeightKg(): Double? = store.bodyWeightKg()
|
||||
suspend fun displayName(): String? = store.displayName()
|
||||
|
||||
/* ---------- Kroppsmätningar ---------- */
|
||||
|
||||
suspend fun fetchBodyMeasurements(): List<BodyMeasurement> {
|
||||
val data = client.execute(
|
||||
"query{bodyMeasurements{id date weightKg musclePercent fatPercent waterPercent}}",
|
||||
token = auth.bearerToken(),
|
||||
)
|
||||
return data["bodyMeasurements"]!!.jsonArray.map { m ->
|
||||
val o = m.jsonObject
|
||||
BodyMeasurement(
|
||||
id = o["id"]!!.jsonPrimitive.content,
|
||||
date = o["date"]?.jsonPrimitive?.contentOrNull() ?: "",
|
||||
weightKg = o["weightKg"]?.jsonPrimitive?.doubleOrNull ?: 0.0,
|
||||
musclePercent = o["musclePercent"]?.jsonPrimitive?.doubleOrNull,
|
||||
fatPercent = o["fatPercent"]?.jsonPrimitive?.doubleOrNull,
|
||||
waterPercent = o["waterPercent"]?.jsonPrimitive?.doubleOrNull,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Ny mätning. Faller tillbaka på gamla updateBodyWeight mot äldre API. */
|
||||
suspend fun addBodyMeasurement(
|
||||
weightKg: Double,
|
||||
dateIso: String? = null,
|
||||
musclePercent: Double? = null,
|
||||
fatPercent: Double? = null,
|
||||
waterPercent: Double? = null,
|
||||
) {
|
||||
try {
|
||||
client.execute(
|
||||
"mutation(\$input: AddBodyMeasurementInput!){addBodyMeasurement(input:\$input){id}}",
|
||||
buildJsonObject {
|
||||
put("input", buildJsonObject {
|
||||
put("weightKg", weightKg)
|
||||
dateIso?.let { put("date", it) }
|
||||
musclePercent?.let { put("musclePercent", it) }
|
||||
fatPercent?.let { put("fatPercent", it) }
|
||||
waterPercent?.let { put("waterPercent", it) }
|
||||
})
|
||||
},
|
||||
auth.bearerToken(),
|
||||
)
|
||||
} catch (e: GraphQlException) {
|
||||
client.execute(
|
||||
"mutation(\$w: Float){updateBodyWeight(bodyWeightKg:\$w){bodyWeightKg}}",
|
||||
buildJsonObject { put("w", weightKg) },
|
||||
auth.bearerToken(),
|
||||
)
|
||||
}
|
||||
store.saveBodyWeight(weightKg)
|
||||
}
|
||||
|
||||
/** null = rör inte fältet, -1 = rensa (serverns konvention). */
|
||||
suspend fun updateBodyMeasurement(
|
||||
id: String,
|
||||
weightKg: Double,
|
||||
dateIso: String?,
|
||||
musclePercent: Double? = null,
|
||||
fatPercent: Double? = null,
|
||||
waterPercent: Double? = null,
|
||||
) {
|
||||
client.execute(
|
||||
"mutation(\$input: UpdateBodyMeasurementInput!){updateBodyMeasurement(input:\$input){id}}",
|
||||
buildJsonObject {
|
||||
put("input", buildJsonObject {
|
||||
put("id", id)
|
||||
put("weightKg", weightKg)
|
||||
dateIso?.let { put("date", it) }
|
||||
musclePercent?.let { put("musclePercent", it) }
|
||||
fatPercent?.let { put("fatPercent", it) }
|
||||
waterPercent?.let { put("waterPercent", it) }
|
||||
})
|
||||
},
|
||||
auth.bearerToken(),
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun deleteBodyMeasurement(id: String) {
|
||||
client.execute(
|
||||
"mutation(\$id: UUID!){deleteBodyMeasurement(id:\$id)}",
|
||||
buildJsonObject { put("id", id) },
|
||||
auth.bearerToken(),
|
||||
)
|
||||
}
|
||||
|
||||
/** Cachade personbästa som (exerciseTypeId, reps) → bästa vikt. */
|
||||
suspend fun personalBests(): Map<Pair<Int, Int>, Double> =
|
||||
db.cacheDao().personalBests().associate { (it.exerciseTypeId to it.reps) to it.weight }
|
||||
|
||||
/* ---------- Referensdata / cache ---------- */
|
||||
|
||||
/** Hämta om all referensdata. Tyst vid nätfel — cachen gäller tills vidare. */
|
||||
fun refreshAllAsync() {
|
||||
scope.launch {
|
||||
runCatching { refreshReferenceData() }
|
||||
runCatching { adoptServerActiveSession() }
|
||||
sync.kick()
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun refreshReferenceData() {
|
||||
val token = auth.bearerToken()
|
||||
|
||||
// Varje sektion för sig, och de nya API-fälten (iconKey,
|
||||
// defaultRestSeconds) med fallback — en äldre server ska aldrig
|
||||
// kunna lämna hela cachen tom.
|
||||
runCatching {
|
||||
val groups = try {
|
||||
client.execute("query{muscleGroups{id name iconKey}}", token = token)
|
||||
} catch (e: GraphQlException) {
|
||||
client.execute("query{muscleGroups{id name}}", token = token)
|
||||
}
|
||||
db.cacheDao().upsertMuscleGroups(
|
||||
groups["muscleGroups"]!!.jsonArray.map {
|
||||
val o = it.jsonObject
|
||||
CachedMuscleGroup(
|
||||
o["id"]!!.jsonPrimitive.int,
|
||||
o["name"]!!.jsonPrimitive.content,
|
||||
o["iconKey"]?.jsonPrimitive?.contentOrNull(),
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
runCatching {
|
||||
val muscles = client.execute("query{muscles{id name muscleGroupId}}", token = token)
|
||||
db.cacheDao().upsertMuscles(
|
||||
muscles["muscles"]!!.jsonArray.map {
|
||||
val o = it.jsonObject
|
||||
CachedMuscle(
|
||||
o["id"]!!.jsonPrimitive.int,
|
||||
o["name"]!!.jsonPrimitive.content,
|
||||
o["muscleGroupId"]?.jsonPrimitive?.intOrNull,
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
runCatching {
|
||||
val pbs = client.execute(
|
||||
"query{personalBests{exerciseTypeId repRecords{reps weight}}}",
|
||||
token = token,
|
||||
)
|
||||
val rows = pbs["personalBests"]!!.jsonArray.flatMap { p ->
|
||||
val po = p.jsonObject
|
||||
val typeId = po["exerciseTypeId"]!!.jsonPrimitive.int
|
||||
po["repRecords"]!!.jsonArray.mapNotNull { r ->
|
||||
val ro = r.jsonObject
|
||||
val reps = ro["reps"]?.jsonPrimitive?.intOrNull ?: return@mapNotNull null
|
||||
val weight = ro["weight"]?.jsonPrimitive?.doubleOrNull ?: return@mapNotNull null
|
||||
eu.brassepc.fitnessdroid.data.local.CachedPb(
|
||||
key = "$typeId-$reps",
|
||||
exerciseTypeId = typeId,
|
||||
reps = reps,
|
||||
weight = weight,
|
||||
)
|
||||
}
|
||||
}
|
||||
db.cacheDao().clearPersonalBests()
|
||||
db.cacheDao().upsertPersonalBests(rows)
|
||||
}
|
||||
|
||||
runCatching {
|
||||
val prof = client.execute("query{myProfile{bodyWeightKg displayName}}", token = token)
|
||||
val p = prof["myProfile"]?.jsonObject
|
||||
store.saveBodyWeight(p?.get("bodyWeightKg")?.jsonPrimitive?.doubleOrNull)
|
||||
store.saveDisplayName(p?.get("displayName")?.jsonPrimitive?.contentOrNull())
|
||||
}
|
||||
|
||||
val favIds: Set<Int> = runCatching {
|
||||
client.execute("query{favoriteExercises{id}}", token = token)["favoriteExercises"]!!
|
||||
.jsonArray.map { it.jsonObject["id"]!!.jsonPrimitive.int }.toSet()
|
||||
}.getOrDefault(emptySet())
|
||||
|
||||
val typeFields =
|
||||
"id name description metValue tracksWeight tracksReps tracksDistance tracksDuration isBodyweight"
|
||||
val types = try {
|
||||
client.execute(
|
||||
"query{exerciseTypes{$typeFields defaultRestSeconds exerciseTypeMuscles{muscle{id}}}}",
|
||||
token = token,
|
||||
)
|
||||
} catch (e: GraphQlException) {
|
||||
client.execute(
|
||||
"query{exerciseTypes{$typeFields exerciseTypeMuscles{muscle{id}}}}",
|
||||
token = token,
|
||||
)
|
||||
}
|
||||
db.cacheDao().replaceExerciseTypes(
|
||||
types["exerciseTypes"]!!.jsonArray.map {
|
||||
val o = it.jsonObject
|
||||
CachedExerciseType(
|
||||
id = o["id"]!!.jsonPrimitive.int,
|
||||
name = o["name"]!!.jsonPrimitive.content,
|
||||
description = o["description"]?.jsonPrimitive?.contentOrNull(),
|
||||
metValue = o["metValue"]?.jsonPrimitive?.doubleOrNull ?: 5.0,
|
||||
tracksWeight = o["tracksWeight"]?.jsonPrimitive?.booleanOrNull ?: false,
|
||||
tracksReps = o["tracksReps"]?.jsonPrimitive?.booleanOrNull ?: false,
|
||||
tracksDistance = o["tracksDistance"]?.jsonPrimitive?.booleanOrNull ?: false,
|
||||
tracksDuration = o["tracksDuration"]?.jsonPrimitive?.booleanOrNull ?: false,
|
||||
isBodyweight = o["isBodyweight"]?.jsonPrimitive?.booleanOrNull ?: false,
|
||||
defaultRestSeconds = o["defaultRestSeconds"]?.jsonPrimitive?.intOrNull,
|
||||
muscleIds = o["exerciseTypeMuscles"]?.jsonArray
|
||||
?.mapNotNull { m -> m.jsonObject["muscle"]?.jsonObject?.get("id")?.jsonPrimitive?.intOrNull }
|
||||
?.joinToString(",") ?: "",
|
||||
isFavorite = o["id"]!!.jsonPrimitive.int in favIds,
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
// Startkort: mallar + favoritpass
|
||||
val cards = mutableListOf<CachedStartCard>()
|
||||
runCatching {
|
||||
val templates = client.execute(
|
||||
"query{workoutTemplates{id name exercises{order exerciseType{id}}}}",
|
||||
token = token,
|
||||
)
|
||||
templates["workoutTemplates"]!!.jsonArray.forEachIndexed { i, t ->
|
||||
val o = t.jsonObject
|
||||
val ids = o["exercises"]!!.jsonArray
|
||||
.sortedBy { e -> e.jsonObject["order"]?.jsonPrimitive?.intOrNull ?: 0 }
|
||||
.mapNotNull { e -> e.jsonObject["exerciseType"]?.jsonObject?.get("id")?.jsonPrimitive?.intOrNull }
|
||||
cards += CachedStartCard(
|
||||
key = "template-${o["id"]!!.jsonPrimitive.int}",
|
||||
title = o["name"]!!.jsonPrimitive.content,
|
||||
subtitle = "Mall · ${ids.size} övningar",
|
||||
exerciseTypeIds = ids.joinToString(","),
|
||||
templateId = o["id"]!!.jsonPrimitive.int,
|
||||
sortOrder = i,
|
||||
)
|
||||
}
|
||||
}
|
||||
runCatching {
|
||||
val favs = client.execute(
|
||||
"query{favoriteGymSessions{id name gymSessionExercises{order exerciseType{id}}}}",
|
||||
token = token,
|
||||
)
|
||||
favs["favoriteGymSessions"]!!.jsonArray.forEachIndexed { i, s ->
|
||||
val o = s.jsonObject
|
||||
val ids = o["gymSessionExercises"]!!.jsonArray
|
||||
.sortedBy { e -> e.jsonObject["order"]?.jsonPrimitive?.intOrNull ?: 0 }
|
||||
.mapNotNull { e -> e.jsonObject["exerciseType"]?.jsonObject?.get("id")?.jsonPrimitive?.intOrNull }
|
||||
cards += CachedStartCard(
|
||||
key = "favsession-${o["id"]!!.jsonPrimitive.int}",
|
||||
title = o["name"]?.jsonPrimitive?.contentOrNull() ?: "Favoritpass",
|
||||
subtitle = "Favorit · ${ids.size} övningar",
|
||||
exerciseTypeIds = ids.joinToString(","),
|
||||
sortOrder = 100 + i,
|
||||
)
|
||||
}
|
||||
}
|
||||
db.cacheDao().clearStartCards()
|
||||
db.cacheDao().upsertStartCards(cards)
|
||||
}
|
||||
|
||||
/** Om servern har ett aktivt pass men appen saknar ett: ta över det lokalt. */
|
||||
private suspend fun adoptServerActiveSession() {
|
||||
if (db.sessionDao().activeSessionIdNow() != null) return
|
||||
val token = auth.bearerToken()
|
||||
val data = client.execute(
|
||||
"query{activeGymSession{id name notes startTime gymSessionExercises{id order notes exerciseType{id} sets{id order reps weight distanceMeters durationSeconds rpe isWarmup isCompleted}}}}",
|
||||
token = token,
|
||||
)
|
||||
val s = data["activeGymSession"] ?: return
|
||||
if (s is kotlinx.serialization.json.JsonNull) return
|
||||
val o = s.jsonObject
|
||||
val sessionId = db.sessionDao().insertSession(
|
||||
LocalSession(
|
||||
serverId = o["id"]!!.jsonPrimitive.int,
|
||||
name = o["name"]?.jsonPrimitive?.contentOrNull(),
|
||||
notes = o["notes"]?.jsonPrimitive?.contentOrNull(),
|
||||
startedAtEpochMs = System.currentTimeMillis(),
|
||||
)
|
||||
)
|
||||
o["gymSessionExercises"]?.jsonArray?.forEach { e ->
|
||||
val eo = e.jsonObject
|
||||
val exId = db.sessionDao().insertExercise(
|
||||
LocalExercise(
|
||||
sessionId = sessionId,
|
||||
serverId = eo["id"]!!.jsonPrimitive.int,
|
||||
exerciseTypeId = eo["exerciseType"]!!.jsonObject["id"]!!.jsonPrimitive.int,
|
||||
order = eo["order"]?.jsonPrimitive?.intOrNull ?: 0,
|
||||
notes = eo["notes"]?.jsonPrimitive?.contentOrNull(),
|
||||
)
|
||||
)
|
||||
eo["sets"]?.jsonArray?.forEach { st ->
|
||||
val so = st.jsonObject
|
||||
db.sessionDao().insertSet(
|
||||
LocalSet(
|
||||
exerciseId = exId,
|
||||
serverId = so["id"]!!.jsonPrimitive.int,
|
||||
order = so["order"]?.jsonPrimitive?.intOrNull ?: 0,
|
||||
reps = so["reps"]?.jsonPrimitive?.intOrNull,
|
||||
weight = so["weight"]?.jsonPrimitive?.doubleOrNull,
|
||||
distanceMeters = so["distanceMeters"]?.jsonPrimitive?.doubleOrNull,
|
||||
durationSeconds = so["durationSeconds"]?.jsonPrimitive?.intOrNull,
|
||||
rpe = so["rpe"]?.jsonPrimitive?.doubleOrNull,
|
||||
isWarmup = so["isWarmup"]?.jsonPrimitive?.booleanOrNull ?: false,
|
||||
isCompleted = so["isCompleted"]?.jsonPrimitive?.booleanOrNull ?: true,
|
||||
loggedAtEpochMs = System.currentTimeMillis(),
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun refreshLastPerformance(exerciseTypeId: Int) {
|
||||
scope.launch {
|
||||
runCatching {
|
||||
val data = client.execute(
|
||||
"query(\$id:Int!){lastExercisePerformance(exerciseTypeId:\$id){sets{reps weight distanceMeters durationSeconds}}}",
|
||||
buildJsonObject { put("id", exerciseTypeId) },
|
||||
auth.bearerToken(),
|
||||
)
|
||||
val perf = data["lastExercisePerformance"] ?: return@launch
|
||||
if (perf is kotlinx.serialization.json.JsonNull) return@launch
|
||||
val best = perf.jsonObject["sets"]?.jsonArray?.lastOrNull()?.jsonObject ?: return@launch
|
||||
db.cacheDao().updateLastPerformance(
|
||||
exerciseTypeId,
|
||||
best["weight"]?.jsonPrimitive?.doubleOrNull,
|
||||
best["reps"]?.jsonPrimitive?.intOrNull,
|
||||
best["durationSeconds"]?.jsonPrimitive?.intOrNull,
|
||||
best["distanceMeters"]?.jsonPrimitive?.doubleOrNull,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun toggleFavoriteExercise(exerciseTypeId: Int, favorite: Boolean) {
|
||||
scope.launch {
|
||||
db.cacheDao().setExerciseFavorite(exerciseTypeId, favorite)
|
||||
runCatching {
|
||||
val mutation = if (favorite) {
|
||||
"mutation(\$id:Int!){addFavoriteExercise(exerciseTypeId:\$id){id}}"
|
||||
} else {
|
||||
"mutation(\$id:Int!){removeFavoriteExercise(exerciseTypeId:\$id)}"
|
||||
}
|
||||
client.execute(mutation, buildJsonObject { put("id", exerciseTypeId) }, auth.bearerToken())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Full detalj för ett avslutat pass. */
|
||||
suspend fun fetchSessionDetail(id: Int): SessionDetail {
|
||||
val data = client.execute(
|
||||
"query(\$id:Int!){gymSession(id:\$id){id name notes date startTime endTime durationSecondsOverride estimatedCalories gymSessionExercises{order exerciseType{name} sets{order reps weight distanceMeters durationSeconds rpe isWarmup}}}}",
|
||||
buildJsonObject { put("id", id) },
|
||||
auth.bearerToken(),
|
||||
)
|
||||
val o = data["gymSession"]!!.jsonObject
|
||||
return SessionDetail(
|
||||
id = o["id"]!!.jsonPrimitive.int,
|
||||
name = o["name"]?.jsonPrimitive?.contentOrNull() ?: "Pass",
|
||||
date = (o["date"]?.jsonPrimitive?.contentOrNull()
|
||||
?: o["startTime"]?.jsonPrimitive?.contentOrNull()).orEmpty(),
|
||||
notes = o["notes"]?.jsonPrimitive?.contentOrNull(),
|
||||
calories = o["estimatedCalories"]?.jsonPrimitive?.doubleOrNull,
|
||||
durationMinutes = durationMinutes(
|
||||
o["durationSecondsOverride"]?.jsonPrimitive?.intOrNull,
|
||||
o["startTime"]?.jsonPrimitive?.contentOrNull(),
|
||||
o["endTime"]?.jsonPrimitive?.contentOrNull(),
|
||||
),
|
||||
exercises = o["gymSessionExercises"]!!.jsonArray
|
||||
.sortedBy { it.jsonObject["order"]?.jsonPrimitive?.intOrNull ?: 0 }
|
||||
.map { e ->
|
||||
val eo = e.jsonObject
|
||||
SessionDetailExercise(
|
||||
name = eo["exerciseType"]?.jsonObject?.get("name")?.jsonPrimitive?.contentOrNull()
|
||||
?: "Övning",
|
||||
sets = eo["sets"]!!.jsonArray.map(::parseHistorySet)
|
||||
.sortedBy { it.order },
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Historik för en övning: tidigare pass med dess set, plus tyngsta
|
||||
* lyften de senaste tre månaderna. Filtreras klient-side ur de
|
||||
* senaste avslutade passen.
|
||||
*/
|
||||
suspend fun fetchExerciseHistory(exerciseTypeId: Int, limit: Int = 40): ExerciseHistory {
|
||||
val data = client.execute(
|
||||
"query(\$s:String,\$l:Int){gymSessions(status:\$s,limit:\$l){name date startTime gymSessionExercises{exerciseType{id} sets{order reps weight distanceMeters durationSeconds rpe isWarmup}}}}",
|
||||
buildJsonObject { put("s", "completed"); put("l", limit) },
|
||||
auth.bearerToken(),
|
||||
)
|
||||
val entries = data["gymSessions"]!!.jsonArray.mapNotNull { s ->
|
||||
val o = s.jsonObject
|
||||
val sets = o["gymSessionExercises"]!!.jsonArray
|
||||
.filter {
|
||||
it.jsonObject["exerciseType"]?.jsonObject?.get("id")?.jsonPrimitive?.intOrNull == exerciseTypeId
|
||||
}
|
||||
.flatMap { it.jsonObject["sets"]!!.jsonArray }
|
||||
.map(::parseHistorySet)
|
||||
.sortedBy { it.order }
|
||||
if (sets.isEmpty()) return@mapNotNull null
|
||||
ExerciseHistoryEntry(
|
||||
date = (o["date"]?.jsonPrimitive?.contentOrNull()
|
||||
?: o["startTime"]?.jsonPrimitive?.contentOrNull()).orEmpty().take(10),
|
||||
sessionName = o["name"]?.jsonPrimitive?.contentOrNull() ?: "Pass",
|
||||
sets = sets,
|
||||
)
|
||||
}.sortedByDescending { it.date }
|
||||
|
||||
val cutoff = java.time.LocalDate.now().minusMonths(3).toString()
|
||||
val topLifts = entries
|
||||
.filter { it.date >= cutoff }
|
||||
.flatMap { entry ->
|
||||
entry.sets.filter { !it.isWarmup && it.weight != null }
|
||||
.map { TopLift(entry.date, it.weight!!, it.reps) }
|
||||
}
|
||||
.sortedWith(compareByDescending<TopLift> { it.weight }.thenByDescending { it.reps ?: 0 })
|
||||
.take(10)
|
||||
|
||||
return ExerciseHistory(entries = entries, topLifts = topLifts)
|
||||
}
|
||||
|
||||
private fun parseHistorySet(element: kotlinx.serialization.json.JsonElement): HistorySet {
|
||||
val so = element.jsonObject
|
||||
return HistorySet(
|
||||
order = so["order"]?.jsonPrimitive?.intOrNull ?: 0,
|
||||
weight = so["weight"]?.jsonPrimitive?.doubleOrNull,
|
||||
reps = so["reps"]?.jsonPrimitive?.intOrNull,
|
||||
distanceMeters = so["distanceMeters"]?.jsonPrimitive?.doubleOrNull,
|
||||
durationSeconds = so["durationSeconds"]?.jsonPrimitive?.intOrNull,
|
||||
rpe = so["rpe"]?.jsonPrimitive?.doubleOrNull,
|
||||
isWarmup = so["isWarmup"]?.jsonPrimitive?.booleanOrNull ?: false,
|
||||
)
|
||||
}
|
||||
|
||||
/** 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(
|
||||
"query(\$id:Int!){gymSessionStats(id:\$id){durationMinutes estimatedCalories totalSets totalReps totalVolumeKg exerciseBreakdown{exerciseTypeId exerciseName allocatedSeconds estimatedCalories totalSets totalReps maxWeight volumeKg}}}",
|
||||
buildJsonObject { put("id", id) },
|
||||
auth.bearerToken(),
|
||||
)
|
||||
val o = data["gymSessionStats"]!!.jsonObject
|
||||
return SessionStats(
|
||||
durationMinutes = o["durationMinutes"]?.jsonPrimitive?.intOrNull,
|
||||
estimatedCalories = o["estimatedCalories"]?.jsonPrimitive?.doubleOrNull,
|
||||
totalSets = o["totalSets"]?.jsonPrimitive?.intOrNull ?: 0,
|
||||
totalReps = o["totalReps"]?.jsonPrimitive?.intOrNull ?: 0,
|
||||
totalVolumeKg = o["totalVolumeKg"]?.jsonPrimitive?.doubleOrNull ?: 0.0,
|
||||
breakdown = o["exerciseBreakdown"]?.jsonArray?.map { e ->
|
||||
val eo = e.jsonObject
|
||||
val typeId = eo["exerciseTypeId"]?.jsonPrimitive?.intOrNull
|
||||
ExerciseStats(
|
||||
exerciseTypeId = typeId,
|
||||
name = eo["exerciseName"]?.jsonPrimitive?.contentOrNull() ?: "Övning",
|
||||
allocatedSeconds = eo["allocatedSeconds"]?.jsonPrimitive?.intOrNull,
|
||||
estimatedCalories = eo["estimatedCalories"]?.jsonPrimitive?.doubleOrNull,
|
||||
metValue = typeId?.let { db.cacheDao().exerciseType(it)?.metValue },
|
||||
)
|
||||
}.orEmpty(),
|
||||
)
|
||||
}
|
||||
|
||||
/** Historik hämtas direkt (ingen offline-garanti i v1). */
|
||||
suspend fun fetchHistory(limit: Int = 30): List<HistoryItem> {
|
||||
val data = client.execute(
|
||||
"query(\$s:String,\$l:Int){gymSessions(status:\$s,limit:\$l){id name date startTime endTime durationSecondsOverride estimatedCalories gymSessionExercises{id sets{id weight reps}}}}",
|
||||
buildJsonObject { put("s", "completed"); put("l", limit) },
|
||||
auth.bearerToken(),
|
||||
)
|
||||
return data["gymSessions"]!!.jsonArray.map { s ->
|
||||
val o = s.jsonObject
|
||||
val sets = o["gymSessionExercises"]!!.jsonArray.flatMap { it.jsonObject["sets"]!!.jsonArray }
|
||||
val volume = sets.sumOf { st ->
|
||||
val so = st.jsonObject
|
||||
(so["weight"]?.jsonPrimitive?.doubleOrNull ?: 0.0) * (so["reps"]?.jsonPrimitive?.intOrNull ?: 0)
|
||||
}
|
||||
HistoryItem(
|
||||
id = o["id"]!!.jsonPrimitive.int,
|
||||
name = o["name"]?.jsonPrimitive?.contentOrNull() ?: "Pass",
|
||||
date = o["date"]?.jsonPrimitive?.contentOrNull()
|
||||
?: o["startTime"]?.jsonPrimitive?.contentOrNull() ?: "",
|
||||
exerciseCount = o["gymSessionExercises"]!!.jsonArray.size,
|
||||
setCount = sets.size,
|
||||
volumeKg = volume,
|
||||
calories = o["estimatedCalories"]?.jsonPrimitive?.doubleOrNull,
|
||||
durationMinutes = durationMinutes(
|
||||
o["durationSecondsOverride"]?.jsonPrimitive?.intOrNull,
|
||||
o["startTime"]?.jsonPrimitive?.contentOrNull(),
|
||||
o["endTime"]?.jsonPrimitive?.contentOrNull(),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Passlängd i minuter: manuell rättning vinner, annars slut − start. */
|
||||
private fun durationMinutes(overrideSeconds: Int?, start: String?, end: String?): Int? {
|
||||
overrideSeconds?.let { return it / 60 }
|
||||
if (start == null || end == null) return null
|
||||
return runCatching {
|
||||
val s = java.time.OffsetDateTime.parse(start)
|
||||
val e = java.time.OffsetDateTime.parse(end)
|
||||
java.time.Duration.between(s, e).toMinutes().toInt().takeIf { it >= 0 }
|
||||
}.getOrNull()
|
||||
}
|
||||
}
|
||||
|
||||
data class HistoryItem(
|
||||
val id: Int,
|
||||
val name: String,
|
||||
val date: String,
|
||||
val exerciseCount: Int,
|
||||
val setCount: Int,
|
||||
val volumeKg: Double,
|
||||
val calories: Double?,
|
||||
val durationMinutes: Int? = null,
|
||||
)
|
||||
|
||||
data class HistorySet(
|
||||
val order: Int,
|
||||
val weight: Double?,
|
||||
val reps: Int?,
|
||||
val distanceMeters: Double?,
|
||||
val durationSeconds: Int?,
|
||||
val rpe: Double?,
|
||||
val isWarmup: Boolean,
|
||||
)
|
||||
|
||||
data class SessionDetail(
|
||||
val id: Int,
|
||||
val name: String,
|
||||
val date: String,
|
||||
val notes: String?,
|
||||
val calories: Double?,
|
||||
val durationMinutes: Int? = null,
|
||||
val exercises: List<SessionDetailExercise>,
|
||||
)
|
||||
|
||||
data class SessionDetailExercise(
|
||||
val name: String,
|
||||
val sets: List<HistorySet>,
|
||||
)
|
||||
|
||||
data class ExerciseHistoryEntry(
|
||||
val date: String,
|
||||
val sessionName: String,
|
||||
val sets: List<HistorySet>,
|
||||
)
|
||||
|
||||
data class TopLift(val date: String, val weight: Double, val reps: Int?)
|
||||
|
||||
data class ExerciseHistory(
|
||||
val entries: List<ExerciseHistoryEntry>,
|
||||
val topLifts: List<TopLift>,
|
||||
)
|
||||
|
||||
data class SessionStats(
|
||||
val durationMinutes: Int?,
|
||||
val estimatedCalories: Double?,
|
||||
val totalSets: Int,
|
||||
val totalReps: Int,
|
||||
val totalVolumeKg: Double,
|
||||
val breakdown: List<ExerciseStats>,
|
||||
)
|
||||
|
||||
data class ExerciseStats(
|
||||
val exerciseTypeId: Int?,
|
||||
val name: String,
|
||||
val allocatedSeconds: Int?,
|
||||
val estimatedCalories: Double?,
|
||||
val metValue: Double?,
|
||||
)
|
||||
|
||||
data class BodyMeasurement(
|
||||
val id: String,
|
||||
val date: String,
|
||||
val weightKg: Double,
|
||||
val musclePercent: Double?,
|
||||
val fatPercent: Double?,
|
||||
val waterPercent: 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
|
||||
102
app/src/main/java/eu/brassepc/fitnessdroid/data/RestTimer.kt
Normal file
102
app/src/main/java/eu/brassepc/fitnessdroid/data/RestTimer.kt
Normal file
@@ -0,0 +1,102 @@
|
||||
package eu.brassepc.fitnessdroid.data
|
||||
|
||||
import android.content.Context
|
||||
import android.media.RingtoneManager
|
||||
import android.os.VibrationEffect
|
||||
import android.os.VibratorManager
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
data class RestState(
|
||||
val totalSeconds: Int,
|
||||
val endsAtEpochMs: Long,
|
||||
val secondsLeft: Int,
|
||||
val finished: Boolean = false,
|
||||
)
|
||||
|
||||
/**
|
||||
* Vilotimern. Räknar mot en absolut sluttid (överlever att appen pausas)
|
||||
* och larmar enligt inställningarna när tiden är ute.
|
||||
*/
|
||||
class RestTimerController(
|
||||
private val context: Context,
|
||||
private val settingsStore: SettingsStore,
|
||||
private val scope: CoroutineScope,
|
||||
) {
|
||||
private val _state = MutableStateFlow<RestState?>(null)
|
||||
val state: StateFlow<RestState?> = _state
|
||||
|
||||
private var ticker: Job? = null
|
||||
|
||||
fun start(seconds: Int? = null) {
|
||||
scope.launch {
|
||||
val settings = settingsStore.settings.first()
|
||||
val total = seconds ?: settings.defaultRestSeconds
|
||||
_state.value = RestState(
|
||||
totalSeconds = total,
|
||||
endsAtEpochMs = System.currentTimeMillis() + total * 1000L,
|
||||
secondsLeft = total,
|
||||
)
|
||||
startTicker()
|
||||
}
|
||||
}
|
||||
|
||||
fun adjust(deltaSeconds: Int) {
|
||||
val current = _state.value ?: return
|
||||
if (current.finished) return
|
||||
_state.value = current.copy(
|
||||
endsAtEpochMs = (current.endsAtEpochMs + deltaSeconds * 1000L)
|
||||
.coerceAtLeast(System.currentTimeMillis()),
|
||||
totalSeconds = (current.totalSeconds + deltaSeconds).coerceAtLeast(5),
|
||||
)
|
||||
}
|
||||
|
||||
fun dismiss() {
|
||||
ticker?.cancel()
|
||||
_state.value = null
|
||||
}
|
||||
|
||||
private fun startTicker() {
|
||||
ticker?.cancel()
|
||||
ticker = scope.launch {
|
||||
while (true) {
|
||||
val current = _state.value ?: return@launch
|
||||
val left = (((current.endsAtEpochMs - System.currentTimeMillis()) + 999) / 1000)
|
||||
.coerceAtLeast(0).toInt()
|
||||
if (left != current.secondsLeft) {
|
||||
_state.value = current.copy(secondsLeft = left)
|
||||
}
|
||||
if (left <= 0) {
|
||||
_state.value = _state.value?.copy(secondsLeft = 0, finished = true)
|
||||
alert()
|
||||
// Låt "klart"-läget synas en stund, försvinn sedan självmant.
|
||||
delay(8_000)
|
||||
if (_state.value?.finished == true) _state.value = null
|
||||
return@launch
|
||||
}
|
||||
delay(250)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun alert() {
|
||||
when (settingsStore.settings.first().restAlert) {
|
||||
RestAlert.SOUND -> runCatching {
|
||||
val uri = RingtoneManager.getDefaultUri(RingtoneManager.TYPE_NOTIFICATION)
|
||||
RingtoneManager.getRingtone(context, uri)?.play()
|
||||
}
|
||||
RestAlert.VIBRATE -> runCatching {
|
||||
val vm = context.getSystemService(Context.VIBRATOR_MANAGER_SERVICE) as VibratorManager
|
||||
vm.defaultVibrator.vibrate(
|
||||
VibrationEffect.createWaveform(longArrayOf(0, 350, 150, 350, 150, 500), -1)
|
||||
)
|
||||
}
|
||||
RestAlert.VISUAL, RestAlert.NONE -> Unit
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package eu.brassepc.fitnessdroid.data
|
||||
|
||||
import android.content.Context
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.intPreferencesKey
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import androidx.datastore.preferences.preferencesDataStore
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
|
||||
private val Context.settingsDataStore by preferencesDataStore(name = "settings")
|
||||
|
||||
enum class RestTimerStyle { FULLSCREEN, BANNER }
|
||||
enum class RestAlert { SOUND, VIBRATE, VISUAL, NONE }
|
||||
|
||||
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,
|
||||
/** Egna stångvikter i skivkalkylatorn (utöver snabbvalen 20/0 kg) */
|
||||
val customBarWeights: List<Double> = listOf(15.0),
|
||||
)
|
||||
|
||||
class SettingsStore(private val context: Context) {
|
||||
|
||||
val settings: Flow<AppSettings> = context.settingsDataStore.data.map { prefs ->
|
||||
AppSettings(
|
||||
restTimerStyle = prefs[KEY_TIMER_STYLE]?.let {
|
||||
runCatching { RestTimerStyle.valueOf(it) }.getOrNull()
|
||||
} ?: RestTimerStyle.FULLSCREEN,
|
||||
restAlert = prefs[KEY_REST_ALERT]?.let {
|
||||
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,
|
||||
customBarWeights = prefs[KEY_CUSTOM_BARS]?.split(",")
|
||||
?.mapNotNull { it.toDoubleOrNull() }
|
||||
// migrera från gamla enkel-värdet
|
||||
?: prefs[KEY_CUSTOM_BAR]?.toDoubleOrNull()?.let { listOf(it) }
|
||||
?: listOf(15.0),
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun addCustomBarWeight(value: Double) {
|
||||
val v = value.coerceIn(0.1, 100.0)
|
||||
context.settingsDataStore.edit { prefs ->
|
||||
val current = prefs[KEY_CUSTOM_BARS]?.split(",")
|
||||
?.mapNotNull { it.toDoubleOrNull() } ?: listOf(15.0)
|
||||
prefs[KEY_CUSTOM_BARS] = (current + v).distinct().sorted().joinToString(",")
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun removeCustomBarWeight(value: Double) {
|
||||
context.settingsDataStore.edit { prefs ->
|
||||
val current = prefs[KEY_CUSTOM_BARS]?.split(",")
|
||||
?.mapNotNull { it.toDoubleOrNull() } ?: listOf(15.0)
|
||||
prefs[KEY_CUSTOM_BARS] = current.filter { it != value }.joinToString(",")
|
||||
}
|
||||
}
|
||||
|
||||
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 }
|
||||
}
|
||||
|
||||
suspend fun setRestAlert(value: RestAlert) {
|
||||
context.settingsDataStore.edit { it[KEY_REST_ALERT] = value.name }
|
||||
}
|
||||
|
||||
suspend fun setDefaultRestSeconds(value: Int) {
|
||||
context.settingsDataStore.edit { it[KEY_REST_SECONDS] = value.coerceIn(10, 600) }
|
||||
}
|
||||
|
||||
companion object {
|
||||
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")
|
||||
private val KEY_CUSTOM_BAR = stringPreferencesKey("custom_bar_weight")
|
||||
private val KEY_CUSTOM_BARS = stringPreferencesKey("custom_bar_weights")
|
||||
}
|
||||
}
|
||||
213
app/src/main/java/eu/brassepc/fitnessdroid/data/SyncEngine.kt
Normal file
213
app/src/main/java/eu/brassepc/fitnessdroid/data/SyncEngine.kt
Normal file
@@ -0,0 +1,213 @@
|
||||
package eu.brassepc.fitnessdroid.data
|
||||
|
||||
import eu.brassepc.fitnessdroid.data.local.AppDatabase
|
||||
import eu.brassepc.fitnessdroid.data.local.PendingOp
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.int
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import kotlinx.serialization.json.put
|
||||
import java.io.IOException
|
||||
|
||||
object OpKind {
|
||||
const val START_SESSION = "start_session"
|
||||
const val ADD_EXERCISE = "add_exercise"
|
||||
const val ADD_SET = "add_set"
|
||||
const val UPDATE_SET = "update_set"
|
||||
/** targetLocalId bär server-id:t — den lokala raden är redan borta */
|
||||
const val REMOVE_SET = "remove_set"
|
||||
const val COMPLETE_SESSION = "complete_session"
|
||||
}
|
||||
|
||||
enum class SyncStatus { SYNCED, PENDING, OFFLINE, ERROR }
|
||||
|
||||
/**
|
||||
* Kör op-kön mot servern i strikt ordning (FIFO). Varje op pekar på en lokal
|
||||
* rad; server-id:n som kommer tillbaka skrivs in på raden så att efterföljande
|
||||
* ops kan referera dem. Nätfel → backoff och nytt försök; kön ligger kvar i
|
||||
* Room så inget tappas om appen dödas.
|
||||
*/
|
||||
class SyncEngine(
|
||||
private val db: AppDatabase,
|
||||
private val client: GraphQlClient,
|
||||
private val auth: AuthRepository,
|
||||
private val scope: CoroutineScope,
|
||||
) {
|
||||
private val mutex = Mutex()
|
||||
private val _status = MutableStateFlow(SyncStatus.SYNCED)
|
||||
val status: StateFlow<SyncStatus> = _status
|
||||
val pendingCount = db.opDao().pendingCount()
|
||||
|
||||
/** Puffa igång kön; ofarlig att kalla ofta. */
|
||||
fun kick() {
|
||||
scope.launch { drain() }
|
||||
}
|
||||
|
||||
suspend fun drain() {
|
||||
mutex.withLock {
|
||||
while (true) {
|
||||
val op = db.opDao().next() ?: break
|
||||
try {
|
||||
execute(op)
|
||||
db.opDao().done(op.id)
|
||||
_status.value = if (db.opDao().pendingCountNow() == 0) {
|
||||
SyncStatus.SYNCED
|
||||
} else {
|
||||
SyncStatus.PENDING
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
// Inget nät — försök igen senare, rör inte kön.
|
||||
_status.value = SyncStatus.OFFLINE
|
||||
scheduleRetry(op)
|
||||
return
|
||||
} catch (e: GraphQlException) {
|
||||
// Servern sa nej. Behåll op:en för felsökning men fortsätt inte —
|
||||
// efterföljande ops kan bero på den här.
|
||||
db.opDao().update(
|
||||
op.copy(attempts = op.attempts + 1, lastError = e.message)
|
||||
)
|
||||
_status.value = SyncStatus.ERROR
|
||||
scheduleRetry(op, longDelay = true)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var retryScheduled = false
|
||||
private fun scheduleRetry(op: PendingOp, longDelay: Boolean = false) {
|
||||
if (retryScheduled) return
|
||||
retryScheduled = true
|
||||
scope.launch {
|
||||
val backoff = if (longDelay) 30_000L else 5_000L
|
||||
delay(backoff * (op.attempts + 1).coerceAtMost(6))
|
||||
retryScheduled = false
|
||||
drain()
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun execute(op: PendingOp) {
|
||||
val dao = db.sessionDao()
|
||||
when (op.kind) {
|
||||
OpKind.START_SESSION -> {
|
||||
val session = dao.session(op.targetLocalId) ?: return
|
||||
if (session.serverId != null) return
|
||||
val input = buildJsonObject {
|
||||
session.name?.let { put("name", it) }
|
||||
session.notes?.let { put("notes", it) }
|
||||
}
|
||||
val data = client.execute(
|
||||
"mutation(\$input: StartGymSessionInput!){startGymSession(input:\$input){id}}",
|
||||
buildJsonObject { put("input", input) },
|
||||
auth.bearerToken(),
|
||||
)
|
||||
val id = data["startGymSession"]!!.jsonObject["id"]!!.jsonPrimitive.int
|
||||
dao.updateSession(session.copy(serverId = id))
|
||||
}
|
||||
|
||||
OpKind.ADD_EXERCISE -> {
|
||||
val exercise = dao.exercise(op.targetLocalId) ?: return
|
||||
if (exercise.serverId != null) return
|
||||
val session = dao.session(exercise.sessionId) ?: return
|
||||
val sessionServerId = session.serverId
|
||||
?: throw GraphQlException(listOf("Passet är inte synkat än"))
|
||||
val input = buildJsonObject {
|
||||
put("gymSessionId", sessionServerId)
|
||||
put("exerciseTypeId", exercise.exerciseTypeId)
|
||||
put("order", exercise.order)
|
||||
exercise.notes?.let { put("notes", it) }
|
||||
}
|
||||
val data = client.execute(
|
||||
"mutation(\$input: AddSessionExerciseInput!){addExerciseToSession(input:\$input){id}}",
|
||||
buildJsonObject { put("input", input) },
|
||||
auth.bearerToken(),
|
||||
)
|
||||
val id = data["addExerciseToSession"]!!.jsonObject["id"]!!.jsonPrimitive.int
|
||||
dao.updateExercise(exercise.copy(serverId = id))
|
||||
}
|
||||
|
||||
OpKind.ADD_SET, OpKind.UPDATE_SET -> {
|
||||
val set = dao.set(op.targetLocalId) ?: return
|
||||
if (op.kind == OpKind.ADD_SET && set.serverId != null) return
|
||||
val exercise = dao.exercise(set.exerciseId) ?: return
|
||||
val exerciseServerId = exercise.serverId
|
||||
?: throw GraphQlException(listOf("Övningen är inte synkad än"))
|
||||
val fields = buildJsonObject {
|
||||
if (op.kind == OpKind.ADD_SET) {
|
||||
put("sessionExerciseId", exerciseServerId)
|
||||
} else {
|
||||
put("id", set.serverId ?: throw GraphQlException(listOf("Setet är inte synkat än")))
|
||||
}
|
||||
set.reps?.let { put("reps", it) }
|
||||
set.weight?.let { put("weight", it) }
|
||||
set.distanceMeters?.let { put("distanceMeters", it) }
|
||||
set.durationSeconds?.let { put("durationSeconds", it) }
|
||||
set.rpe?.let { put("rpe", it) }
|
||||
put("isWarmup", set.isWarmup)
|
||||
put("isCompleted", set.isCompleted)
|
||||
put("order", set.order)
|
||||
}
|
||||
if (op.kind == OpKind.ADD_SET) {
|
||||
val data = client.execute(
|
||||
"mutation(\$input: AddSessionSetInput!){addSessionSet(input:\$input){id}}",
|
||||
buildJsonObject { put("input", fields) },
|
||||
auth.bearerToken(),
|
||||
)
|
||||
val id = data["addSessionSet"]!!.jsonObject["id"]!!.jsonPrimitive.int
|
||||
dao.updateSet(set.copy(serverId = id))
|
||||
} else {
|
||||
client.execute(
|
||||
"mutation(\$input: UpdateSessionSetInput!){updateSessionSet(input:\$input){id}}",
|
||||
buildJsonObject { put("input", fields) },
|
||||
auth.bearerToken(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
OpKind.REMOVE_SET -> {
|
||||
client.execute(
|
||||
"mutation(\$id: Int!){removeSessionSet(id:\$id)}",
|
||||
buildJsonObject { put("id", op.targetLocalId.toInt()) },
|
||||
auth.bearerToken(),
|
||||
)
|
||||
}
|
||||
|
||||
OpKind.COMPLETE_SESSION -> {
|
||||
val session = dao.session(op.targetLocalId) ?: return
|
||||
val sessionServerId = session.serverId
|
||||
?: throw GraphQlException(listOf("Passet är inte synkat än"))
|
||||
// Rättad starttid: flytta passets datum innan det avslutas.
|
||||
session.editedStartEpochMs?.let { startMs ->
|
||||
val iso = java.time.Instant.ofEpochMilli(startMs).toString()
|
||||
client.execute(
|
||||
"mutation(\$input: UpdateGymSessionInput!){updateGymSession(input:\$input){id date}}",
|
||||
buildJsonObject {
|
||||
put("input", buildJsonObject {
|
||||
put("id", sessionServerId)
|
||||
put("date", iso)
|
||||
})
|
||||
},
|
||||
auth.bearerToken(),
|
||||
)
|
||||
}
|
||||
val input = buildJsonObject {
|
||||
put("id", sessionServerId)
|
||||
session.name?.let { put("name", it) }
|
||||
session.durationSecondsOverride?.let { put("durationSecondsOverride", it) }
|
||||
}
|
||||
client.execute(
|
||||
"mutation(\$input: CompleteGymSessionInput!){completeGymSession(input:\$input){id status}}",
|
||||
buildJsonObject { put("input", input) },
|
||||
auth.bearerToken(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package eu.brassepc.fitnessdroid.data
|
||||
|
||||
import android.content.Context
|
||||
import androidx.datastore.preferences.core.doublePreferencesKey
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.longPreferencesKey
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
@@ -63,6 +64,25 @@ class TokenStore(private val context: Context) {
|
||||
}
|
||||
}
|
||||
|
||||
/** Kroppsvikten cachas från profilen — behövs för kaloriuppskattningen offline. */
|
||||
suspend fun saveBodyWeight(kg: Double?) {
|
||||
context.authDataStore.edit {
|
||||
if (kg == null) it.remove(KEY_BODY_WEIGHT) else it[KEY_BODY_WEIGHT] = kg
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun bodyWeightKg(): Double? =
|
||||
context.authDataStore.data.first()[KEY_BODY_WEIGHT]
|
||||
|
||||
suspend fun saveDisplayName(name: String?) {
|
||||
context.authDataStore.edit {
|
||||
if (name.isNullOrBlank()) it.remove(KEY_DISPLAY_NAME) else it[KEY_DISPLAY_NAME] = name
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun displayName(): String? =
|
||||
context.authDataStore.data.first()[KEY_DISPLAY_NAME]
|
||||
|
||||
/** Rensar sessionen men behåller vald API-url. */
|
||||
suspend fun clearSession() {
|
||||
context.authDataStore.edit {
|
||||
@@ -83,5 +103,7 @@ class TokenStore(private val context: Context) {
|
||||
private val KEY_REFRESH_TOKEN = stringPreferencesKey("refresh_token")
|
||||
private val KEY_EXPIRATION = longPreferencesKey("expiration_epoch_ms")
|
||||
private val KEY_API_URL = stringPreferencesKey("api_url")
|
||||
private val KEY_BODY_WEIGHT = doublePreferencesKey("body_weight_kg")
|
||||
private val KEY_DISPLAY_NAME = stringPreferencesKey("display_name")
|
||||
}
|
||||
}
|
||||
|
||||
109
app/src/main/java/eu/brassepc/fitnessdroid/data/UpdateChecker.kt
Normal file
109
app/src/main/java/eu/brassepc/fitnessdroid/data/UpdateChecker.kt
Normal file
@@ -0,0 +1,109 @@
|
||||
package eu.brassepc.fitnessdroid.data
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import androidx.core.content.FileProvider
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.intOrNull
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import java.io.File
|
||||
import java.security.MessageDigest
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
data class UpdateInfo(
|
||||
val versionCode: Long,
|
||||
val versionName: String,
|
||||
val apkUrl: String,
|
||||
val sha256: String?,
|
||||
)
|
||||
|
||||
/**
|
||||
* Uppdateringskoll mot släppsidans version.json (skrivs av CI:t).
|
||||
* Nedladdning till appens cache + verifiering, sedan Androids
|
||||
* installationsdialog — samma signeringsnyckel gör att den uppdaterar
|
||||
* rakt över befintlig installation.
|
||||
*/
|
||||
class UpdateChecker(private val context: Context) {
|
||||
|
||||
private val http = OkHttpClient.Builder()
|
||||
.connectTimeout(10, TimeUnit.SECONDS)
|
||||
.readTimeout(120, TimeUnit.SECONDS)
|
||||
.build()
|
||||
|
||||
private fun installedVersionCode(): Long =
|
||||
context.packageManager.getPackageInfo(context.packageName, 0).longVersionCode
|
||||
|
||||
/** null = uppdaterad, nätfel eller trasig manifest — stör aldrig användaren. */
|
||||
suspend fun check(): UpdateInfo? = withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
val request = Request.Builder().url(VERSION_URL).build()
|
||||
http.newCall(request).execute().use { response ->
|
||||
if (!response.isSuccessful) return@withContext null
|
||||
val o = Json.parseToJsonElement(response.body!!.string()).jsonObject
|
||||
val info = UpdateInfo(
|
||||
versionCode = (o["versionCode"]?.jsonPrimitive?.intOrNull ?: 0).toLong(),
|
||||
versionName = o["versionName"]?.jsonPrimitive?.content ?: "?",
|
||||
apkUrl = o["apkUrl"]?.jsonPrimitive?.content ?: return@withContext null,
|
||||
sha256 = o["sha256"]?.jsonPrimitive?.content,
|
||||
)
|
||||
if (info.versionCode > installedVersionCode()) info else null
|
||||
}
|
||||
}.getOrNull()
|
||||
}
|
||||
|
||||
/** Laddar ner APK:n till cachen, verifierar sha256, returnerar filen. */
|
||||
suspend fun download(info: UpdateInfo, onProgress: (Float) -> Unit): File =
|
||||
withContext(Dispatchers.IO) {
|
||||
val dir = File(context.cacheDir, "updates").apply { mkdirs() }
|
||||
val file = File(dir, "FitnessDroid-${info.versionCode}.apk")
|
||||
val request = Request.Builder().url(info.apkUrl).build()
|
||||
http.newCall(request).execute().use { response ->
|
||||
if (!response.isSuccessful) error("HTTP ${response.code}")
|
||||
val body = response.body!!
|
||||
val total = body.contentLength().takeIf { it > 0 }
|
||||
val digest = MessageDigest.getInstance("SHA-256")
|
||||
body.byteStream().use { input ->
|
||||
file.outputStream().use { output ->
|
||||
val buffer = ByteArray(64 * 1024)
|
||||
var read: Int
|
||||
var done = 0L
|
||||
while (input.read(buffer).also { read = it } != -1) {
|
||||
output.write(buffer, 0, read)
|
||||
digest.update(buffer, 0, read)
|
||||
done += read
|
||||
total?.let { onProgress(done.toFloat() / it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
val actual = digest.digest().joinToString("") { "%02x".format(it) }
|
||||
if (info.sha256 != null && !actual.equals(info.sha256, ignoreCase = true)) {
|
||||
file.delete()
|
||||
error("sha256 stämmer inte — avbryter")
|
||||
}
|
||||
}
|
||||
file
|
||||
}
|
||||
|
||||
/** Öppnar Androids installationsdialog för den nedladdade APK:n. */
|
||||
fun install(file: File) {
|
||||
val uri = FileProvider.getUriForFile(
|
||||
context, "${context.packageName}.fileprovider", file,
|
||||
)
|
||||
context.startActivity(
|
||||
Intent(Intent.ACTION_VIEW).apply {
|
||||
setDataAndType(uri, "application/vnd.android.package-archive")
|
||||
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val VERSION_URL =
|
||||
"https://gitea.brasse-pc.eu/brasse/FitnessDroid/releases/download/latest/version.json"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package eu.brassepc.fitnessdroid.data.local
|
||||
|
||||
import android.content.Context
|
||||
import androidx.room.Database
|
||||
import androidx.room.Room
|
||||
import androidx.room.RoomDatabase
|
||||
import androidx.room.migration.Migration
|
||||
import androidx.sqlite.db.SupportSQLiteDatabase
|
||||
|
||||
@Database(
|
||||
entities = [
|
||||
LocalSession::class,
|
||||
LocalExercise::class,
|
||||
LocalSet::class,
|
||||
PendingOp::class,
|
||||
CachedExerciseType::class,
|
||||
CachedMuscleGroup::class,
|
||||
CachedMuscle::class,
|
||||
CachedStartCard::class,
|
||||
CachedPb::class,
|
||||
],
|
||||
version = 4,
|
||||
exportSchema = false,
|
||||
)
|
||||
abstract class AppDatabase : RoomDatabase() {
|
||||
abstract fun sessionDao(): SessionDao
|
||||
abstract fun opDao(): OpDao
|
||||
abstract fun cacheDao(): CacheDao
|
||||
|
||||
companion object {
|
||||
private val MIGRATION_1_2 = object : Migration(1, 2) {
|
||||
override fun migrate(db: SupportSQLiteDatabase) {
|
||||
db.execSQL("ALTER TABLE local_session ADD COLUMN durationSecondsOverride INTEGER")
|
||||
}
|
||||
}
|
||||
|
||||
private val MIGRATION_2_3 = object : Migration(2, 3) {
|
||||
override fun migrate(db: SupportSQLiteDatabase) {
|
||||
db.execSQL("ALTER TABLE local_session ADD COLUMN editedStartEpochMs INTEGER")
|
||||
}
|
||||
}
|
||||
|
||||
private val MIGRATION_3_4 = object : Migration(3, 4) {
|
||||
override fun migrate(db: SupportSQLiteDatabase) {
|
||||
db.execSQL(
|
||||
"CREATE TABLE IF NOT EXISTS `cached_pb` (" +
|
||||
"`key` TEXT NOT NULL, `exerciseTypeId` INTEGER NOT NULL, " +
|
||||
"`reps` INTEGER NOT NULL, `weight` REAL NOT NULL, " +
|
||||
"PRIMARY KEY(`key`))"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun build(context: Context): AppDatabase =
|
||||
Room.databaseBuilder(context, AppDatabase::class.java, "fitnessdroid.db")
|
||||
.addMigrations(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4)
|
||||
.fallbackToDestructiveMigration()
|
||||
.build()
|
||||
}
|
||||
}
|
||||
147
app/src/main/java/eu/brassepc/fitnessdroid/data/local/Daos.kt
Normal file
147
app/src/main/java/eu/brassepc/fitnessdroid/data/local/Daos.kt
Normal file
@@ -0,0 +1,147 @@
|
||||
package eu.brassepc.fitnessdroid.data.local
|
||||
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Insert
|
||||
import androidx.room.Query
|
||||
import androidx.room.Transaction
|
||||
import androidx.room.Update
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
@Dao
|
||||
interface SessionDao {
|
||||
@Insert
|
||||
suspend fun insertSession(session: LocalSession): Long
|
||||
|
||||
@Insert
|
||||
suspend fun insertExercise(exercise: LocalExercise): Long
|
||||
|
||||
@Insert
|
||||
suspend fun insertSet(set: LocalSet): Long
|
||||
|
||||
@Update
|
||||
suspend fun updateSession(session: LocalSession)
|
||||
|
||||
@Update
|
||||
suspend fun updateExercise(exercise: LocalExercise)
|
||||
|
||||
@Update
|
||||
suspend fun updateSet(set: LocalSet)
|
||||
|
||||
@Query("SELECT * FROM local_session WHERE status = 'active' ORDER BY id DESC LIMIT 1")
|
||||
fun activeSession(): Flow<LocalSession?>
|
||||
|
||||
@Query("SELECT * FROM local_session WHERE id = :id")
|
||||
suspend fun session(id: Long): LocalSession?
|
||||
|
||||
@Query("SELECT id FROM local_session WHERE status = 'active' ORDER BY id DESC LIMIT 1")
|
||||
suspend fun activeSessionIdNow(): Long?
|
||||
|
||||
@Query("SELECT * FROM local_exercise WHERE sessionId = :sessionId ORDER BY `order`")
|
||||
fun exercises(sessionId: Long): Flow<List<LocalExercise>>
|
||||
|
||||
@Query("SELECT * FROM local_exercise WHERE id = :id")
|
||||
suspend fun exercise(id: Long): LocalExercise?
|
||||
|
||||
@Query("SELECT * FROM local_set WHERE exerciseId IN (SELECT id FROM local_exercise WHERE sessionId = :sessionId) ORDER BY `order`")
|
||||
fun setsForSession(sessionId: Long): Flow<List<LocalSet>>
|
||||
|
||||
@Query("SELECT * FROM local_set WHERE id = :id")
|
||||
suspend fun set(id: Long): LocalSet?
|
||||
|
||||
@Query("SELECT COUNT(*) FROM local_exercise WHERE sessionId = :sessionId")
|
||||
suspend fun exerciseCount(sessionId: Long): Int
|
||||
|
||||
@Query("SELECT COUNT(*) FROM local_set WHERE exerciseId = :exerciseId")
|
||||
suspend fun setCount(exerciseId: Long): Int
|
||||
|
||||
@Query("DELETE FROM local_exercise WHERE id = :id")
|
||||
suspend fun deleteExercise(id: Long)
|
||||
|
||||
@Query("DELETE FROM local_set WHERE id = :id")
|
||||
suspend fun deleteSet(id: Long)
|
||||
}
|
||||
|
||||
@Dao
|
||||
interface OpDao {
|
||||
@Insert
|
||||
suspend fun enqueue(op: PendingOp): Long
|
||||
|
||||
@Update
|
||||
suspend fun update(op: PendingOp)
|
||||
|
||||
@Query("SELECT * FROM pending_op ORDER BY id LIMIT 1")
|
||||
suspend fun next(): PendingOp?
|
||||
|
||||
@Query("DELETE FROM pending_op WHERE id = :id")
|
||||
suspend fun done(id: Long)
|
||||
|
||||
@Query("SELECT COUNT(*) FROM pending_op")
|
||||
fun pendingCount(): Flow<Int>
|
||||
|
||||
@Query("SELECT COUNT(*) FROM pending_op")
|
||||
suspend fun pendingCountNow(): Int
|
||||
}
|
||||
|
||||
@Dao
|
||||
interface CacheDao {
|
||||
@Query("SELECT * FROM cached_exercise_type ORDER BY isFavorite DESC, name")
|
||||
fun exerciseTypes(): Flow<List<CachedExerciseType>>
|
||||
|
||||
@Query("SELECT * FROM cached_exercise_type WHERE id = :id")
|
||||
suspend fun exerciseType(id: Int): CachedExerciseType?
|
||||
|
||||
@Query("SELECT * FROM cached_muscle_group ORDER BY name")
|
||||
fun muscleGroups(): Flow<List<CachedMuscleGroup>>
|
||||
|
||||
@Query("SELECT * FROM cached_muscle ORDER BY name")
|
||||
fun muscles(): Flow<List<CachedMuscle>>
|
||||
|
||||
@Query("SELECT * FROM cached_start_card ORDER BY sortOrder")
|
||||
fun startCards(): Flow<List<CachedStartCard>>
|
||||
|
||||
@Transaction
|
||||
suspend fun replaceExerciseTypes(rows: List<CachedExerciseType>) {
|
||||
// Behåll lastWeight/lastReps-fälten vid refresh — de fylls på separat.
|
||||
for (row in rows) {
|
||||
val old = exerciseType(row.id)
|
||||
upsertExerciseType(
|
||||
if (old != null) row.copy(
|
||||
lastWeight = old.lastWeight,
|
||||
lastReps = old.lastReps,
|
||||
lastDurationSeconds = old.lastDurationSeconds,
|
||||
lastDistanceMeters = old.lastDistanceMeters,
|
||||
) else row
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@androidx.room.Upsert
|
||||
suspend fun upsertExerciseType(row: CachedExerciseType)
|
||||
|
||||
@androidx.room.Upsert
|
||||
suspend fun upsertMuscleGroups(rows: List<CachedMuscleGroup>)
|
||||
|
||||
@androidx.room.Upsert
|
||||
suspend fun upsertMuscles(rows: List<CachedMuscle>)
|
||||
|
||||
@Query("DELETE FROM cached_start_card")
|
||||
suspend fun clearStartCards()
|
||||
|
||||
@androidx.room.Upsert
|
||||
suspend fun upsertStartCards(rows: List<CachedStartCard>)
|
||||
|
||||
@Query("UPDATE cached_exercise_type SET lastWeight = :weight, lastReps = :reps, lastDurationSeconds = :duration, lastDistanceMeters = :distance WHERE id = :id")
|
||||
suspend fun updateLastPerformance(id: Int, weight: Double?, reps: Int?, duration: Int?, distance: Double?)
|
||||
|
||||
@Query("UPDATE cached_exercise_type SET isFavorite = :favorite WHERE id = :id")
|
||||
suspend fun setExerciseFavorite(id: Int, favorite: Boolean)
|
||||
|
||||
@Query("SELECT * FROM cached_pb")
|
||||
suspend fun personalBests(): List<CachedPb>
|
||||
|
||||
@Query("DELETE FROM cached_pb")
|
||||
suspend fun clearPersonalBests()
|
||||
|
||||
@androidx.room.Upsert
|
||||
suspend fun upsertPersonalBests(rows: List<CachedPb>)
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
package eu.brassepc.fitnessdroid.data.local
|
||||
|
||||
import androidx.room.Entity
|
||||
import androidx.room.Index
|
||||
import androidx.room.PrimaryKey
|
||||
|
||||
/**
|
||||
* Lokal-först-modell: passet lever i Room medan du tränar och synkas till
|
||||
* servern via op-kön ([PendingOp]) i bakgrunden. serverId är null tills
|
||||
* motsvarande mutation gått igenom — det driver synk-indikatorerna i UI:t.
|
||||
*/
|
||||
|
||||
@Entity(tableName = "local_session")
|
||||
data class LocalSession(
|
||||
@PrimaryKey(autoGenerate = true) val id: Long = 0,
|
||||
val serverId: Int? = null,
|
||||
val name: String? = null,
|
||||
val notes: String? = null,
|
||||
val startedAtEpochMs: Long,
|
||||
/** "active" eller "completed" (lokalt avslutad, ev. ej synkad än) */
|
||||
val status: String = "active",
|
||||
/** Manuellt rättad passtid — skickas som durationSecondsOverride vid avslut */
|
||||
val durationSecondsOverride: Int? = null,
|
||||
/** Manuellt rättad starttid — skickas som updateGymSession(date) vid avslut */
|
||||
val editedStartEpochMs: Long? = null,
|
||||
)
|
||||
|
||||
@Entity(
|
||||
tableName = "local_exercise",
|
||||
indices = [Index("sessionId")],
|
||||
)
|
||||
data class LocalExercise(
|
||||
@PrimaryKey(autoGenerate = true) val id: Long = 0,
|
||||
val sessionId: Long,
|
||||
val serverId: Int? = null,
|
||||
val exerciseTypeId: Int,
|
||||
val order: Int,
|
||||
val notes: String? = null,
|
||||
)
|
||||
|
||||
@Entity(
|
||||
tableName = "local_set",
|
||||
indices = [Index("exerciseId")],
|
||||
)
|
||||
data class LocalSet(
|
||||
@PrimaryKey(autoGenerate = true) val id: Long = 0,
|
||||
val exerciseId: Long,
|
||||
val serverId: Int? = null,
|
||||
val order: Int,
|
||||
val reps: Int? = null,
|
||||
val weight: Double? = null,
|
||||
val distanceMeters: Double? = null,
|
||||
val durationSeconds: Int? = null,
|
||||
val rpe: Double? = null,
|
||||
val isWarmup: Boolean = false,
|
||||
val isCompleted: Boolean = true,
|
||||
val loggedAtEpochMs: Long,
|
||||
)
|
||||
|
||||
/** Kön av mutationer som väntar på att nå servern. Körs strikt i id-ordning. */
|
||||
@Entity(tableName = "pending_op")
|
||||
data class PendingOp(
|
||||
@PrimaryKey(autoGenerate = true) val id: Long = 0,
|
||||
/** se [eu.brassepc.fitnessdroid.data.OpKind] */
|
||||
val kind: String,
|
||||
/** Lokalt id på raden operationen gäller (session/övning/set beroende på kind) */
|
||||
val targetLocalId: Long,
|
||||
val createdAtEpochMs: Long,
|
||||
val attempts: Int = 0,
|
||||
val lastError: String? = null,
|
||||
)
|
||||
|
||||
/* ---------- Referensdata-cache (funkar offline) ---------- */
|
||||
|
||||
@Entity(tableName = "cached_exercise_type")
|
||||
data class CachedExerciseType(
|
||||
@PrimaryKey val id: Int,
|
||||
val name: String,
|
||||
val description: String? = null,
|
||||
val metValue: Double = 5.0,
|
||||
val tracksWeight: Boolean = false,
|
||||
val tracksReps: Boolean = false,
|
||||
val tracksDistance: Boolean = false,
|
||||
val tracksDuration: Boolean = false,
|
||||
val isBodyweight: Boolean = false,
|
||||
/** kommaseparerade muskel-id:n */
|
||||
val muscleIds: String = "",
|
||||
val isFavorite: Boolean = false,
|
||||
/** från API-tillägget ExerciseType.defaultRestSeconds; null → appens standardvila */
|
||||
val defaultRestSeconds: Int? = null,
|
||||
/** Senast kända prestation (för förifyllnad offline) */
|
||||
val lastWeight: Double? = null,
|
||||
val lastReps: Int? = null,
|
||||
val lastDurationSeconds: Int? = null,
|
||||
val lastDistanceMeters: Double? = null,
|
||||
)
|
||||
|
||||
@Entity(tableName = "cached_muscle_group")
|
||||
data class CachedMuscleGroup(
|
||||
@PrimaryKey val id: Int,
|
||||
val name: String,
|
||||
/** från API-tillägget MuscleGroup.iconKey; null → namnheuristik */
|
||||
val iconKey: String? = null,
|
||||
)
|
||||
|
||||
@Entity(tableName = "cached_muscle")
|
||||
data class CachedMuscle(
|
||||
@PrimaryKey val id: Int,
|
||||
val name: String,
|
||||
val muscleGroupId: Int? = null,
|
||||
)
|
||||
|
||||
/** Personbästa per övning och rep-antal — för PB-markering vid avslut. */
|
||||
@Entity(tableName = "cached_pb")
|
||||
data class CachedPb(
|
||||
/** "<exerciseTypeId>-<reps>" */
|
||||
@PrimaryKey val key: String,
|
||||
val exerciseTypeId: Int,
|
||||
val reps: Int,
|
||||
val weight: Double,
|
||||
)
|
||||
|
||||
/** Favoritpass/mallar som visas på hemskärmen, cachade för offline-start. */
|
||||
@Entity(tableName = "cached_start_card")
|
||||
data class CachedStartCard(
|
||||
/** "template-<id>" eller "favsession-<id>" */
|
||||
@PrimaryKey val key: String,
|
||||
val title: String,
|
||||
val subtitle: String,
|
||||
/** kommaseparerade exerciseTypeId:n i ordning — används för att starta passet */
|
||||
val exerciseTypeIds: String,
|
||||
val templateId: Int? = null,
|
||||
val sortOrder: Int = 0,
|
||||
)
|
||||
225
app/src/main/java/eu/brassepc/fitnessdroid/ui/AppRoot.kt
Normal file
225
app/src/main/java/eu/brassepc/fitnessdroid/ui/AppRoot.kt
Normal file
@@ -0,0 +1,225 @@
|
||||
package eu.brassepc.fitnessdroid.ui
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.List
|
||||
import androidx.compose.material.icons.filled.BarChart
|
||||
import androidx.compose.material.icons.filled.Home
|
||||
import androidx.compose.material.icons.filled.Person
|
||||
import androidx.compose.material.icons.filled.PlayArrow
|
||||
import androidx.compose.material3.FloatingActionButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.NavigationBar
|
||||
import androidx.compose.material3.NavigationBarItem
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Surface
|
||||
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.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import androidx.navigation.NavHostController
|
||||
import androidx.navigation.NavType
|
||||
import androidx.navigation.compose.NavHost
|
||||
import androidx.navigation.compose.composable
|
||||
import androidx.navigation.compose.currentBackStackEntryAsState
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import androidx.navigation.navArgument
|
||||
import eu.brassepc.fitnessdroid.ui.history.HistoryDetailScreen
|
||||
import eu.brassepc.fitnessdroid.ui.history.HistoryScreen
|
||||
import eu.brassepc.fitnessdroid.ui.home.HomeScreen
|
||||
import eu.brassepc.fitnessdroid.ui.picker.ExercisePickerScreen
|
||||
import eu.brassepc.fitnessdroid.ui.profile.ProfileScreen
|
||||
import eu.brassepc.fitnessdroid.ui.session.SessionScreen
|
||||
import eu.brassepc.fitnessdroid.ui.settings.SettingsScreen
|
||||
import eu.brassepc.fitnessdroid.ui.stats.StatsScreen
|
||||
|
||||
object Routes {
|
||||
const val HOME = "home"
|
||||
const val HISTORY = "history"
|
||||
const val HISTORY_DETAIL = "history/{id}"
|
||||
const val STATS = "stats"
|
||||
const val PROFILE = "profile"
|
||||
const val SETTINGS = "settings"
|
||||
const val SESSION = "session"
|
||||
const val PICKER = "picker"
|
||||
|
||||
fun historyDetail(id: Int) = "history/$id"
|
||||
}
|
||||
|
||||
private data class TabItem(val route: String, val label: String, val icon: ImageVector)
|
||||
|
||||
private val tabs = listOf(
|
||||
TabItem(Routes.HOME, "Hem", Icons.Default.Home),
|
||||
TabItem(Routes.HISTORY, "Pass", Icons.AutoMirrored.Filled.List),
|
||||
TabItem(Routes.STATS, "Statistik", Icons.Default.BarChart),
|
||||
TabItem(Routes.PROFILE, "Profil", Icons.Default.Person),
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun AppRoot(rootViewModel: RootViewModel = viewModel(factory = RootViewModel.Factory)) {
|
||||
val nav = rememberNavController()
|
||||
val backStack by nav.currentBackStackEntryAsState()
|
||||
val route = backStack?.destination?.route
|
||||
val hasActiveSession by rootViewModel.hasActiveSession.collectAsStateWithLifecycle(false)
|
||||
val restState by rootViewModel.restState.collectAsStateWithLifecycle()
|
||||
val pendingCount by rootViewModel.pendingCount.collectAsStateWithLifecycle(0)
|
||||
|
||||
val showChrome = route in tabs.map { it.route }
|
||||
|
||||
Scaffold(
|
||||
bottomBar = {
|
||||
if (showChrome) {
|
||||
Column {
|
||||
if (hasActiveSession) {
|
||||
MiniPlayer(
|
||||
restSecondsLeft = restState?.secondsLeft,
|
||||
pendingCount = pendingCount,
|
||||
onOpen = { nav.navigate(Routes.SESSION) },
|
||||
)
|
||||
}
|
||||
BottomBar(nav = nav, currentRoute = route, onStart = {
|
||||
rootViewModel.startOrResume { nav.navigate(Routes.SESSION) }
|
||||
})
|
||||
}
|
||||
}
|
||||
},
|
||||
) { padding ->
|
||||
NavHost(
|
||||
navController = nav,
|
||||
startDestination = Routes.HOME,
|
||||
modifier = Modifier.padding(padding),
|
||||
) {
|
||||
composable(Routes.HOME) {
|
||||
HomeScreen(
|
||||
onOpenSession = { nav.navigate(Routes.SESSION) },
|
||||
onOpenHistory = { nav.navigate(Routes.HISTORY) },
|
||||
)
|
||||
}
|
||||
composable(Routes.HISTORY) {
|
||||
HistoryScreen(onOpenSession = { id -> nav.navigate(Routes.historyDetail(id)) })
|
||||
}
|
||||
composable(
|
||||
Routes.HISTORY_DETAIL,
|
||||
arguments = listOf(navArgument("id") { type = NavType.IntType }),
|
||||
) { entry ->
|
||||
HistoryDetailScreen(
|
||||
sessionId = entry.arguments?.getInt("id") ?: 0,
|
||||
onBack = { nav.popBackStack() },
|
||||
)
|
||||
}
|
||||
composable(Routes.STATS) { StatsScreen() }
|
||||
composable(Routes.PROFILE) {
|
||||
ProfileScreen(onOpenSettings = { nav.navigate(Routes.SETTINGS) })
|
||||
}
|
||||
composable(Routes.SETTINGS) { SettingsScreen(onBack = { nav.popBackStack() }) }
|
||||
composable(Routes.SESSION) {
|
||||
SessionScreen(
|
||||
onBack = { nav.popBackStack() },
|
||||
onAddExercise = { nav.navigate(Routes.PICKER) },
|
||||
onSessionEnded = {
|
||||
nav.popBackStack(Routes.HOME, inclusive = false)
|
||||
},
|
||||
)
|
||||
}
|
||||
composable(Routes.PICKER) {
|
||||
ExercisePickerScreen(onDone = { nav.popBackStack() })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BottomBar(nav: NavHostController, currentRoute: String?, onStart: () -> Unit) {
|
||||
Box {
|
||||
NavigationBar {
|
||||
tabs.forEachIndexed { index, tab ->
|
||||
if (index == 2) {
|
||||
// plats för mittknappen
|
||||
NavigationBarItem(
|
||||
selected = false,
|
||||
onClick = {},
|
||||
enabled = false,
|
||||
icon = {},
|
||||
label = { Text("") },
|
||||
)
|
||||
}
|
||||
NavigationBarItem(
|
||||
selected = currentRoute == tab.route,
|
||||
onClick = {
|
||||
nav.navigate(tab.route) {
|
||||
popUpTo(Routes.HOME) { saveState = true }
|
||||
launchSingleTop = true
|
||||
restoreState = true
|
||||
}
|
||||
},
|
||||
icon = { Icon(tab.icon, contentDescription = tab.label) },
|
||||
label = { Text(tab.label) },
|
||||
)
|
||||
}
|
||||
}
|
||||
FloatingActionButton(
|
||||
onClick = onStart,
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopCenter)
|
||||
.offset(y = (-6).dp),
|
||||
containerColor = MaterialTheme.colorScheme.primary,
|
||||
contentColor = MaterialTheme.colorScheme.onPrimary,
|
||||
) {
|
||||
Icon(Icons.Default.PlayArrow, contentDescription = "Starta pass")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MiniPlayer(restSecondsLeft: Int?, pendingCount: Int, onOpen: () -> Unit) {
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.secondaryContainer,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = onOpen),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.PlayArrow,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
Column(modifier = Modifier.weight(1f).padding(start = 10.dp)) {
|
||||
Text("Pass pågår", style = MaterialTheme.typography.labelLarge)
|
||||
val sub = buildString {
|
||||
if (restSecondsLeft != null && restSecondsLeft > 0) {
|
||||
append("Vila ${restSecondsLeft / 60}:${"%02d".format(restSecondsLeft % 60)} kvar")
|
||||
} else {
|
||||
append("Tryck för att fortsätta")
|
||||
}
|
||||
if (pendingCount > 0) append(" · $pendingCount osynkat")
|
||||
}
|
||||
Text(
|
||||
sub,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
Text(
|
||||
"ÖPPNA",
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,6 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import eu.brassepc.fitnessdroid.data.AuthState
|
||||
import eu.brassepc.fitnessdroid.ui.home.HomeScreen
|
||||
import eu.brassepc.fitnessdroid.ui.login.LoginScreen
|
||||
|
||||
@Composable
|
||||
@@ -35,7 +34,7 @@ fun FitnessDroidApp(appViewModel: AppViewModel = viewModel(factory = AppViewMode
|
||||
}
|
||||
}
|
||||
AuthState.LoggedOut -> LoginScreen()
|
||||
is AuthState.LoggedIn -> HomeScreen()
|
||||
is AuthState.LoggedIn -> AppRoot()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
package eu.brassepc.fitnessdroid.ui
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.lifecycle.viewmodel.initializer
|
||||
import androidx.lifecycle.viewmodel.viewModelFactory
|
||||
import eu.brassepc.fitnessdroid.data.GymRepository
|
||||
import eu.brassepc.fitnessdroid.data.RestTimerController
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class RootViewModel(
|
||||
private val repo: GymRepository,
|
||||
restTimer: RestTimerController,
|
||||
) : ViewModel() {
|
||||
|
||||
val hasActiveSession = repo.activeSession.map { it != null }
|
||||
val restState = restTimer.state
|
||||
val pendingCount = repo.pendingCount
|
||||
|
||||
private val activeSessionState = repo.activeSession
|
||||
.stateIn(viewModelScope, SharingStarted.Eagerly, null)
|
||||
|
||||
init {
|
||||
repo.refreshAllAsync()
|
||||
}
|
||||
|
||||
/** Mittknappen: öppna pågående pass, eller starta ett fritt pass. */
|
||||
fun startOrResume(onReady: () -> Unit) {
|
||||
viewModelScope.launch {
|
||||
if (activeSessionState.value == null) {
|
||||
repo.startFreeSession()
|
||||
}
|
||||
onReady()
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
val Factory: ViewModelProvider.Factory = viewModelFactory {
|
||||
initializer {
|
||||
val c = appContainer()
|
||||
RootViewModel(c.gymRepository, c.restTimer)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
package eu.brassepc.fitnessdroid.ui.common
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
/** En rad i kaloriuträkningen: en övning med MET, tilldelad tid och kcal. */
|
||||
data class CalorieRow(
|
||||
val name: String,
|
||||
val metValue: Double?,
|
||||
val seconds: Int,
|
||||
val kcal: Double?,
|
||||
)
|
||||
|
||||
/**
|
||||
* Klient-side-uppskattning som speglar serverns ComputeCalories:
|
||||
* passets tid fördelas jämnt över övningarna (tidssatta set utgör golv
|
||||
* för sin övning) och per övning gäller kcal = MET × kroppsvikt × timmar.
|
||||
*/
|
||||
fun estimateCalories(
|
||||
exercises: List<Triple<String, Double, Int>>, // (namn, MET, golv-sekunder från tidssatta set)
|
||||
bodyWeightKg: Double?,
|
||||
totalSeconds: Int,
|
||||
): Pair<Double?, List<CalorieRow>> {
|
||||
if (exercises.isEmpty()) return null to emptyList()
|
||||
val floorsSum = exercises.sumOf { it.third }
|
||||
val leftover = (totalSeconds - floorsSum).coerceAtLeast(0)
|
||||
val perAuto = leftover / exercises.size.toDouble()
|
||||
val rows = exercises.map { (name, met, floor) ->
|
||||
val sec = (floor + perAuto).toInt()
|
||||
CalorieRow(
|
||||
name = name,
|
||||
metValue = met,
|
||||
seconds = sec,
|
||||
kcal = bodyWeightKg?.let { met * it * (sec / 3600.0) },
|
||||
)
|
||||
}
|
||||
val total = if (bodyWeightKg == null) null else rows.sumOf { it.kcal ?: 0.0 }
|
||||
return total to rows
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun CalorieInfoDialog(
|
||||
rows: List<CalorieRow>,
|
||||
bodyWeightKg: Double?,
|
||||
totalKcal: Double?,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text("Så räknas kalorierna") },
|
||||
text = {
|
||||
Column(
|
||||
modifier = Modifier.verticalScroll(rememberScrollState()),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
Text(
|
||||
"Per övning: kcal = MET × kroppsvikt (kg) × tid (timmar). " +
|
||||
"Passets tid fördelas jämnt över övningarna, men set med " +
|
||||
"uppmätt tid räknas alltid in i sin övning.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
if (bodyWeightKg == null) {
|
||||
Text(
|
||||
"Ingen kroppsvikt satt i profilen — därför kan inga " +
|
||||
"kalorier beräknas. Sätt den på webben under Profil.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
} else {
|
||||
Text(
|
||||
"Din kroppsvikt: ${bodyWeightKg.compact()} kg",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
rows.forEach { row ->
|
||||
Row(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(row.name, style = MaterialTheme.typography.bodyMedium)
|
||||
Text(
|
||||
buildString {
|
||||
append("MET ${row.metValue?.compact() ?: "?"}")
|
||||
append(" · ${row.seconds / 60} min")
|
||||
},
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
Text(
|
||||
row.kcal?.let { "${it.toInt()} kcal" } ?: "—",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
}
|
||||
}
|
||||
if (totalKcal != null) {
|
||||
Row(modifier = Modifier.fillMaxWidth().padding(top = 4.dp)) {
|
||||
Text(
|
||||
"Totalt",
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
Text("${totalKcal.toInt()} kcal", style = MaterialTheme.typography.titleSmall)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = onDismiss) { Text("Stäng") }
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package eu.brassepc.fitnessdroid.ui.common
|
||||
|
||||
import eu.brassepc.fitnessdroid.data.HistorySet
|
||||
|
||||
fun Double.compact(): String =
|
||||
if (this % 1.0 == 0.0) toInt().toString() else "%.1f".format(this)
|
||||
|
||||
/** "80 kg × 8 · RPE 8" — samma format överallt där set visas. */
|
||||
fun HistorySet.describe(): String = buildString {
|
||||
weight?.let { append("${it.compact()} kg") }
|
||||
reps?.let {
|
||||
if (isNotEmpty()) append(" × $it") else append("$it reps")
|
||||
}
|
||||
durationSeconds?.let { if (isNotEmpty()) append(" · ${it}s") else append("${it}s") }
|
||||
distanceMeters?.let { if (isNotEmpty()) append(" · ${it.compact()} m") else append("${it.compact()} m") }
|
||||
rpe?.let { append(" · RPE ${it.compact()}") }
|
||||
if (isWarmup) append(" · uppvärmning")
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package eu.brassepc.fitnessdroid.ui.common
|
||||
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.DirectionsRun
|
||||
import androidx.compose.material.icons.filled.Accessibility
|
||||
import androidx.compose.material.icons.filled.AirlineSeatLegroomExtra
|
||||
import androidx.compose.material.icons.filled.FavoriteBorder
|
||||
import androidx.compose.material.icons.filled.FitnessCenter
|
||||
import androidx.compose.material.icons.filled.FrontHand
|
||||
import androidx.compose.material.icons.filled.Grid4x4
|
||||
import androidx.compose.material.icons.filled.SportsGymnastics
|
||||
import androidx.compose.material.icons.filled.SportsMartialArts
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
|
||||
/**
|
||||
* Ikon per muskelgrupp. I första hand via serverns iconKey
|
||||
* (API-tillägget MuscleGroup.iconKey), i andra hand namnheuristik.
|
||||
*/
|
||||
object MuscleIcons {
|
||||
|
||||
fun forKey(iconKey: String?): ImageVector? = when (iconKey?.lowercase()) {
|
||||
"chest" -> Icons.Default.FitnessCenter
|
||||
"back" -> Icons.Default.SportsGymnastics
|
||||
"legs" -> Icons.Default.AirlineSeatLegroomExtra
|
||||
"shoulders" -> Icons.Default.SportsMartialArts
|
||||
"arms" -> Icons.Default.FrontHand
|
||||
"core" -> Icons.Default.Grid4x4
|
||||
"cardio" -> Icons.AutoMirrored.Filled.DirectionsRun
|
||||
"fullbody" -> Icons.Default.Accessibility
|
||||
else -> null
|
||||
}
|
||||
|
||||
fun forGroupName(name: String?): ImageVector {
|
||||
val n = name?.lowercase().orEmpty()
|
||||
return when {
|
||||
"bröst" in n || "chest" in n -> Icons.Default.FitnessCenter
|
||||
"rygg" in n || "back" in n || "lat" in n -> Icons.Default.SportsGymnastics
|
||||
"ben" in n || "leg" in n || "quad" in n || "vad" in n -> Icons.Default.AirlineSeatLegroomExtra
|
||||
"axl" in n || "shoulder" in n || "delt" in n -> Icons.Default.SportsMartialArts
|
||||
"arm" in n || "bicep" in n || "tricep" in n -> Icons.Default.FrontHand
|
||||
"mage" in n || "core" in n || "abs" in n || "bål" in n -> Icons.Default.Grid4x4
|
||||
"kondition" in n || "cardio" in n || "löp" in n -> Icons.AutoMirrored.Filled.DirectionsRun
|
||||
"hel" in n || "full" in n -> Icons.Default.Accessibility
|
||||
else -> Icons.Default.FavoriteBorder
|
||||
}
|
||||
}
|
||||
|
||||
fun resolve(iconKey: String?, groupName: String?): ImageVector =
|
||||
forKey(iconKey) ?: forGroupName(groupName)
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
package eu.brassepc.fitnessdroid.ui.history
|
||||
|
||||
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.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
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.SessionDetail
|
||||
import eu.brassepc.fitnessdroid.data.SessionStats
|
||||
import eu.brassepc.fitnessdroid.ui.appContainer
|
||||
import eu.brassepc.fitnessdroid.ui.common.CalorieInfoDialog
|
||||
import eu.brassepc.fitnessdroid.ui.common.CalorieRow
|
||||
import eu.brassepc.fitnessdroid.ui.common.describe
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
sealed interface DetailUiState {
|
||||
data object Loading : DetailUiState
|
||||
data class Error(val message: String) : DetailUiState
|
||||
data class Ready(
|
||||
val detail: SessionDetail,
|
||||
val stats: SessionStats?,
|
||||
val bodyWeightKg: Double?,
|
||||
) : DetailUiState
|
||||
}
|
||||
|
||||
class HistoryDetailViewModel(private val repo: GymRepository) : ViewModel() {
|
||||
val uiState = MutableStateFlow<DetailUiState>(DetailUiState.Loading)
|
||||
private var loadedId: Int? = null
|
||||
|
||||
fun load(id: Int) {
|
||||
if (loadedId == id && uiState.value is DetailUiState.Ready) return
|
||||
loadedId = id
|
||||
uiState.value = DetailUiState.Loading
|
||||
viewModelScope.launch {
|
||||
uiState.value = try {
|
||||
DetailUiState.Ready(
|
||||
detail = repo.fetchSessionDetail(id),
|
||||
stats = runCatching { repo.fetchSessionStats(id) }.getOrNull(),
|
||||
bodyWeightKg = repo.bodyWeightKg(),
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
DetailUiState.Error("Kunde inte hämta passet — offline?")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
val Factory: ViewModelProvider.Factory = viewModelFactory {
|
||||
initializer { HistoryDetailViewModel(appContainer().gymRepository) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun HistoryDetailScreen(
|
||||
sessionId: Int,
|
||||
onBack: () -> Unit,
|
||||
viewModel: HistoryDetailViewModel = viewModel(factory = HistoryDetailViewModel.Factory),
|
||||
) {
|
||||
LaunchedEffect(sessionId) { viewModel.load(sessionId) }
|
||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = {
|
||||
Text((uiState as? DetailUiState.Ready)?.detail?.name ?: "Pass")
|
||||
},
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Tillbaka")
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
) { padding ->
|
||||
when (val state = uiState) {
|
||||
is DetailUiState.Loading -> {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize().padding(padding),
|
||||
contentAlignment = Alignment.Center,
|
||||
) { CircularProgressIndicator() }
|
||||
}
|
||||
is DetailUiState.Error -> {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize().padding(padding),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(state.message, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
}
|
||||
is DetailUiState.Ready -> {
|
||||
val d = state.detail
|
||||
val stats = state.stats
|
||||
var showCalorieInfo by remember { mutableStateOf(false) }
|
||||
|
||||
if (showCalorieInfo && stats != null) {
|
||||
CalorieInfoDialog(
|
||||
rows = stats.breakdown.map {
|
||||
CalorieRow(
|
||||
name = it.name,
|
||||
metValue = it.metValue,
|
||||
seconds = it.allocatedSeconds ?: 0,
|
||||
kcal = it.estimatedCalories,
|
||||
)
|
||||
},
|
||||
bodyWeightKg = state.bodyWeightKg,
|
||||
totalKcal = stats.estimatedCalories,
|
||||
onDismiss = { showCalorieInfo = false },
|
||||
)
|
||||
}
|
||||
|
||||
val kcalByName = remember(stats) {
|
||||
stats?.breakdown?.groupBy { it.name }?.mapValues { (_, v) ->
|
||||
ArrayDeque(v)
|
||||
} ?: emptyMap()
|
||||
}
|
||||
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize().padding(padding),
|
||||
contentPadding = androidx.compose.foundation.layout.PaddingValues(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
item {
|
||||
Card(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(modifier = Modifier.padding(14.dp)) {
|
||||
Text(
|
||||
buildString {
|
||||
append(d.date.take(10))
|
||||
(d.durationMinutes ?: stats?.durationMinutes)?.let {
|
||||
append(" · ")
|
||||
append(if (it >= 60) "${it / 60}h ${it % 60}m" else "$it min")
|
||||
}
|
||||
if (stats != null) {
|
||||
append(" · ${stats.totalSets} set · ${stats.totalVolumeKg.toInt()} kg volym")
|
||||
}
|
||||
},
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
(stats?.estimatedCalories ?: d.calories)
|
||||
?.let { "${it.toInt()} kcal" }
|
||||
?: "Inga kalorier (kroppsvikt saknas)",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
if (stats != null) {
|
||||
TextButton(onClick = { showCalorieInfo = true }) {
|
||||
Text("Hur räknas detta?")
|
||||
}
|
||||
}
|
||||
}
|
||||
d.notes?.takeIf { it.isNotBlank() }?.let {
|
||||
Text(it, style = MaterialTheme.typography.bodyMedium)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
items(d.exercises) { exercise ->
|
||||
val exerciseKcal = kcalByName[exercise.name]?.removeFirstOrNull()
|
||||
Card(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(
|
||||
modifier = Modifier.padding(14.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
Row {
|
||||
Text(
|
||||
exercise.name,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
exerciseKcal?.estimatedCalories?.let {
|
||||
Text(
|
||||
"${it.toInt()} kcal",
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
}
|
||||
exercise.sets.forEach { set ->
|
||||
Row {
|
||||
Text(
|
||||
"Set ${set.order}",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(end = 10.dp),
|
||||
)
|
||||
Text(
|
||||
set.describe(),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
}
|
||||
if (exercise.sets.isEmpty()) {
|
||||
Text(
|
||||
"Inga set loggade",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
package eu.brassepc.fitnessdroid.ui.history
|
||||
|
||||
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.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.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.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.HistoryItem
|
||||
import eu.brassepc.fitnessdroid.ui.appContainer
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
sealed interface HistoryUiState {
|
||||
data object Loading : HistoryUiState
|
||||
data class Error(val message: String) : HistoryUiState
|
||||
data class Ready(val items: List<HistoryItem>) : HistoryUiState
|
||||
}
|
||||
|
||||
class HistoryViewModel(private val repo: GymRepository) : ViewModel() {
|
||||
val uiState = MutableStateFlow<HistoryUiState>(HistoryUiState.Loading)
|
||||
|
||||
init { load() }
|
||||
|
||||
fun load() {
|
||||
uiState.value = HistoryUiState.Loading
|
||||
viewModelScope.launch {
|
||||
uiState.value = try {
|
||||
HistoryUiState.Ready(repo.fetchHistory())
|
||||
} catch (e: Exception) {
|
||||
HistoryUiState.Error("Kunde inte hämta historiken — offline?")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
val Factory: ViewModelProvider.Factory = viewModelFactory {
|
||||
initializer { HistoryViewModel(appContainer().gymRepository) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(androidx.compose.material3.ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun HistoryScreen(
|
||||
onOpenSession: (Int) -> Unit,
|
||||
viewModel: HistoryViewModel = viewModel(factory = HistoryViewModel.Factory),
|
||||
) {
|
||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
|
||||
when (val state = uiState) {
|
||||
is HistoryUiState.Loading -> {
|
||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
}
|
||||
is HistoryUiState.Error -> {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize().padding(24.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center,
|
||||
) {
|
||||
Text(state.message, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
Button(onClick = viewModel::load, modifier = Modifier.padding(top = 12.dp)) {
|
||||
Text("Försök igen")
|
||||
}
|
||||
}
|
||||
}
|
||||
is HistoryUiState.Ready -> {
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = androidx.compose.foundation.layout.PaddingValues(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
item {
|
||||
Text("Passhistorik", style = MaterialTheme.typography.headlineSmall)
|
||||
}
|
||||
items(state.items, key = { it.id }) { item ->
|
||||
Card(
|
||||
onClick = { onOpenSession(item.id) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Column(modifier = Modifier.padding(14.dp)) {
|
||||
Row {
|
||||
Text(
|
||||
item.name,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
Text(
|
||||
item.date.take(10),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
Text(
|
||||
buildString {
|
||||
item.durationMinutes?.let {
|
||||
append(if (it >= 60) "${it / 60}h ${it % 60}m" else "$it min")
|
||||
append(" · ")
|
||||
}
|
||||
append("${item.exerciseCount} övningar · ${item.setCount} set")
|
||||
if (item.volumeKg > 0) append(" · ${item.volumeKg.toInt()} kg volym")
|
||||
item.calories?.let { append(" · ${it.toInt()} kcal") }
|
||||
},
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 4.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (state.items.isEmpty()) {
|
||||
item {
|
||||
Text(
|
||||
"Inga avslutade pass än — dags att ändra på det!",
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,23 +1,29 @@
|
||||
package eu.brassepc.fitnessdroid.ui.home
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
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.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.Logout
|
||||
import androidx.compose.material.icons.filled.CloudDone
|
||||
import androidx.compose.material.icons.filled.CloudOff
|
||||
import androidx.compose.material.icons.filled.CloudUpload
|
||||
import androidx.compose.material.icons.filled.PlayArrow
|
||||
import androidx.compose.material.icons.filled.Star
|
||||
import androidx.compose.material.icons.filled.SystemUpdate
|
||||
import androidx.compose.material3.AssistChip
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CenterAlignedTopAppBar
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
@@ -26,95 +32,190 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import eu.brassepc.fitnessdroid.data.SyncStatus
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun HomeScreen(viewModel: HomeViewModel = viewModel(factory = HomeViewModel.Factory)) {
|
||||
fun HomeScreen(
|
||||
onOpenSession: () -> Unit,
|
||||
onOpenHistory: () -> Unit,
|
||||
viewModel: HomeViewModel = viewModel(factory = HomeViewModel.Factory),
|
||||
) {
|
||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
val updateState by viewModel.updateState.collectAsStateWithLifecycle()
|
||||
val displayName by viewModel.displayName.collectAsStateWithLifecycle()
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
CenterAlignedTopAppBar(
|
||||
title = { Text("FitnessDroid") },
|
||||
actions = {
|
||||
IconButton(onClick = viewModel::logout) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.Logout,
|
||||
contentDescription = "Logga ut",
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
) { padding ->
|
||||
when (val state = uiState) {
|
||||
is HomeUiState.Loading -> {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
}
|
||||
is HomeUiState.Error -> {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding)
|
||||
.padding(24.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center,
|
||||
) {
|
||||
Text(state.message, color = MaterialTheme.colorScheme.error)
|
||||
Button(onClick = viewModel::load, modifier = Modifier.padding(top = 16.dp)) {
|
||||
Text("Försök igen")
|
||||
}
|
||||
}
|
||||
}
|
||||
is HomeUiState.Ready -> {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding)
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
"Hej ${state.profile.displayName ?: state.profile.username}!",
|
||||
"Tjena ${displayName ?: uiState.username}",
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
)
|
||||
Card(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Text("Profil", style = MaterialTheme.typography.titleMedium)
|
||||
ProfileRow("Användarnamn", state.profile.username)
|
||||
state.profile.email?.let { ProfileRow("E-post", it) }
|
||||
state.profile.bodyWeightKg?.let { ProfileRow("Kroppsvikt", "$it kg") }
|
||||
}
|
||||
}
|
||||
Text(
|
||||
"Pass, loggning, statistik och PB kommer i nästa steg.",
|
||||
"Redo för ett pass?",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
SyncChip(status = uiState.syncStatus, pending = uiState.pendingCount)
|
||||
}
|
||||
|
||||
updateState?.let { update ->
|
||||
Card(modifier = Modifier.fillMaxWidth()) {
|
||||
Row(
|
||||
modifier = Modifier.padding(14.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.SystemUpdate,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
Column(modifier = Modifier.weight(1f).padding(start = 10.dp)) {
|
||||
when (update) {
|
||||
is UpdateState.Available -> {
|
||||
Text(
|
||||
"Ny version: ${update.info.versionName}",
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
)
|
||||
Text(
|
||||
"Laddas ner från släppsidan och installeras ovanpå",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
is UpdateState.Downloading -> {
|
||||
Text("Laddar ner …", style = MaterialTheme.typography.titleSmall)
|
||||
androidx.compose.material3.LinearProgressIndicator(
|
||||
progress = { update.progress },
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 6.dp),
|
||||
)
|
||||
}
|
||||
is UpdateState.Failed -> {
|
||||
Text(
|
||||
update.message,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (update !is UpdateState.Downloading) {
|
||||
Button(onClick = viewModel::startUpdate) {
|
||||
Text(if (update is UpdateState.Failed) "Igen" else "Uppdatera")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ProfileRow(label: String, value: String) {
|
||||
Row(modifier = Modifier.fillMaxWidth()) {
|
||||
if (uiState.hasActiveSession) {
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = onOpenSession),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.PlayArrow,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
Text(
|
||||
label,
|
||||
modifier = Modifier.weight(1f),
|
||||
"Pass pågår — tryck för att fortsätta",
|
||||
modifier = Modifier.padding(start = 10.dp),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Button(
|
||||
onClick = { viewModel.startFree(onOpenSession) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Icon(Icons.Default.PlayArrow, contentDescription = null)
|
||||
Text("Starta fritt pass", modifier = Modifier.padding(start = 6.dp))
|
||||
}
|
||||
}
|
||||
|
||||
if (uiState.startCards.isNotEmpty()) {
|
||||
Text("Favoritpass & mallar", style = MaterialTheme.typography.titleMedium)
|
||||
LazyRow(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
items(uiState.startCards, key = { it.key }) { card ->
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.width(170.dp)
|
||||
.clickable(enabled = !uiState.hasActiveSession) {
|
||||
viewModel.startFromCard(card, onOpenSession)
|
||||
},
|
||||
) {
|
||||
Column(modifier = Modifier.padding(12.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(
|
||||
Icons.Default.Star,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.tertiary,
|
||||
modifier = Modifier.padding(end = 6.dp),
|
||||
)
|
||||
Text(
|
||||
card.title,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
Text(
|
||||
card.subtitle,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 4.dp),
|
||||
)
|
||||
Text(
|
||||
if (uiState.hasActiveSession) "Pass pågår" else "▶ Starta",
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.padding(top = 10.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
AssistChip(
|
||||
onClick = onOpenHistory,
|
||||
label = { Text("Passhistorik") },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SyncChip(status: SyncStatus, pending: Int) {
|
||||
val (icon, text) = when {
|
||||
status == SyncStatus.OFFLINE -> Icons.Default.CloudOff to "Offline · $pending väntar"
|
||||
status == SyncStatus.ERROR -> Icons.Default.CloudOff to "Synkfel"
|
||||
pending > 0 -> Icons.Default.CloudUpload to "$pending osynkat"
|
||||
else -> Icons.Default.CloudDone to "Synkat"
|
||||
}
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(
|
||||
icon,
|
||||
contentDescription = text,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(end = 4.dp),
|
||||
)
|
||||
Text(
|
||||
text,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Text(value)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,54 +6,100 @@ import androidx.lifecycle.viewModelScope
|
||||
import androidx.lifecycle.viewmodel.initializer
|
||||
import androidx.lifecycle.viewmodel.viewModelFactory
|
||||
import eu.brassepc.fitnessdroid.data.AuthRepository
|
||||
import eu.brassepc.fitnessdroid.data.GraphQlException
|
||||
import eu.brassepc.fitnessdroid.data.GymApi
|
||||
import eu.brassepc.fitnessdroid.data.Profile
|
||||
import eu.brassepc.fitnessdroid.data.AuthState
|
||||
import eu.brassepc.fitnessdroid.data.GymRepository
|
||||
import eu.brassepc.fitnessdroid.data.SyncStatus
|
||||
import eu.brassepc.fitnessdroid.data.local.CachedStartCard
|
||||
import eu.brassepc.fitnessdroid.ui.appContainer
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
sealed interface HomeUiState {
|
||||
data object Loading : HomeUiState
|
||||
data class Error(val message: String) : HomeUiState
|
||||
data class Ready(val profile: Profile) : HomeUiState
|
||||
data class HomeUiState(
|
||||
val username: String = "",
|
||||
val hasActiveSession: Boolean = false,
|
||||
val startCards: List<CachedStartCard> = emptyList(),
|
||||
val syncStatus: SyncStatus = SyncStatus.SYNCED,
|
||||
val pendingCount: Int = 0,
|
||||
)
|
||||
|
||||
sealed interface UpdateState {
|
||||
data class Available(val info: eu.brassepc.fitnessdroid.data.UpdateInfo) : UpdateState
|
||||
data class Downloading(val progress: Float) : UpdateState
|
||||
data class Failed(val message: String) : UpdateState
|
||||
}
|
||||
|
||||
class HomeViewModel(
|
||||
private val gymApi: GymApi,
|
||||
private val auth: AuthRepository,
|
||||
private val repo: GymRepository,
|
||||
auth: AuthRepository,
|
||||
private val updateChecker: eu.brassepc.fitnessdroid.data.UpdateChecker,
|
||||
) : ViewModel() {
|
||||
|
||||
private val _uiState = MutableStateFlow<HomeUiState>(HomeUiState.Loading)
|
||||
val uiState: StateFlow<HomeUiState> = _uiState
|
||||
val updateState = kotlinx.coroutines.flow.MutableStateFlow<UpdateState?>(null)
|
||||
|
||||
/** Helnamn från profilen — trevligare hälsning än användarnamnet. */
|
||||
val displayName = kotlinx.coroutines.flow.MutableStateFlow<String?>(null)
|
||||
|
||||
init {
|
||||
load()
|
||||
viewModelScope.launch {
|
||||
displayName.value = repo.displayName()
|
||||
updateChecker.check()?.let { updateState.value = UpdateState.Available(it) }
|
||||
}
|
||||
}
|
||||
|
||||
fun load() {
|
||||
_uiState.value = HomeUiState.Loading
|
||||
fun startUpdate() {
|
||||
val info = (updateState.value as? UpdateState.Available)?.info ?: return
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
_uiState.value = HomeUiState.Ready(gymApi.myProfile())
|
||||
} catch (e: GraphQlException) {
|
||||
_uiState.value = HomeUiState.Error(e.message ?: "Något gick fel")
|
||||
updateState.value = UpdateState.Downloading(0f)
|
||||
val file = updateChecker.download(info) { p ->
|
||||
updateState.value = UpdateState.Downloading(p)
|
||||
}
|
||||
updateChecker.install(file)
|
||||
// Tillbaka till "tillgänglig" om installationen avbryts.
|
||||
updateState.value = UpdateState.Available(info)
|
||||
} catch (e: Exception) {
|
||||
_uiState.value = HomeUiState.Error("Kunde inte nå servern")
|
||||
updateState.value = UpdateState.Failed("Nedladdningen misslyckades — försök igen")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun logout() {
|
||||
viewModelScope.launch { auth.logout() }
|
||||
val uiState = combine(
|
||||
repo.activeSession,
|
||||
repo.startCards,
|
||||
repo.syncStatus,
|
||||
repo.pendingCount,
|
||||
auth.state,
|
||||
) { session, cards, sync, pending, authState ->
|
||||
HomeUiState(
|
||||
username = (authState as? AuthState.LoggedIn)?.username ?: "",
|
||||
hasActiveSession = session != null,
|
||||
startCards = cards,
|
||||
syncStatus = sync,
|
||||
pendingCount = pending,
|
||||
)
|
||||
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), HomeUiState())
|
||||
|
||||
fun startFree(onReady: () -> Unit) {
|
||||
viewModelScope.launch {
|
||||
repo.startFreeSession()
|
||||
onReady()
|
||||
}
|
||||
}
|
||||
|
||||
fun startFromCard(card: CachedStartCard, onReady: () -> Unit) {
|
||||
viewModelScope.launch {
|
||||
repo.startFromCard(card)
|
||||
onReady()
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
val Factory: ViewModelProvider.Factory = viewModelFactory {
|
||||
initializer {
|
||||
val container = appContainer()
|
||||
HomeViewModel(container.gymApi, container.authRepository)
|
||||
val c = appContainer()
|
||||
HomeViewModel(c.gymRepository, c.authRepository, c.updateChecker)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
package eu.brassepc.fitnessdroid.ui.picker
|
||||
|
||||
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.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
import androidx.compose.material.icons.filled.Star
|
||||
import androidx.compose.material.icons.filled.StarBorder
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import eu.brassepc.fitnessdroid.ui.common.MuscleIcons
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun ExercisePickerScreen(
|
||||
onDone: () -> Unit,
|
||||
viewModel: PickerViewModel = viewModel(factory = PickerViewModel.Factory),
|
||||
) {
|
||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text("Lägg till övning") },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onDone) {
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Tillbaka")
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
) { padding ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding)
|
||||
.padding(horizontal = 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = uiState.query,
|
||||
onValueChange = viewModel::onQueryChange,
|
||||
placeholder = { Text("Sök övning …") },
|
||||
leadingIcon = { Icon(Icons.Default.Search, contentDescription = null) },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
|
||||
LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
item {
|
||||
FilterChip(
|
||||
selected = uiState.selectedGroupId == null,
|
||||
onClick = { viewModel.selectGroup(null) },
|
||||
label = { Text("Alla") },
|
||||
)
|
||||
}
|
||||
items(uiState.groups, key = { it.id }) { group ->
|
||||
FilterChip(
|
||||
selected = uiState.selectedGroupId == group.id,
|
||||
onClick = { viewModel.selectGroup(group.id) },
|
||||
label = { Text(group.name) },
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
MuscleIcons.resolve(group.iconKey, group.name),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(16.dp),
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (uiState.musclesInGroup.isNotEmpty()) {
|
||||
LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
item {
|
||||
FilterChip(
|
||||
selected = uiState.selectedMuscleId == null,
|
||||
onClick = { viewModel.selectMuscle(null) },
|
||||
label = { Text("Alla i gruppen") },
|
||||
)
|
||||
}
|
||||
items(uiState.musclesInGroup, key = { it.id }) { muscle ->
|
||||
FilterChip(
|
||||
selected = uiState.selectedMuscleId == muscle.id,
|
||||
onClick = { viewModel.selectMuscle(muscle.id) },
|
||||
label = { Text(muscle.name) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LazyColumn(
|
||||
modifier = Modifier.weight(1f),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
items(uiState.exercises, key = { it.id }) { row ->
|
||||
Surface(
|
||||
onClick = { viewModel.pick(row.id, onDone) },
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
color = MaterialTheme.colorScheme.surface,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 8.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Surface(
|
||||
shape = CircleShape,
|
||||
color = MaterialTheme.colorScheme.surfaceVariant,
|
||||
modifier = Modifier.size(38.dp),
|
||||
) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
Icon(
|
||||
row.icon,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.size(20.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.padding(horizontal = 10.dp),
|
||||
) {
|
||||
Text(row.name, style = MaterialTheme.typography.titleSmall)
|
||||
Text(
|
||||
row.subtitle,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
row.badges.forEach { badge ->
|
||||
Text(
|
||||
badge,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(start = 6.dp),
|
||||
)
|
||||
}
|
||||
IconButton(onClick = { viewModel.toggleFavorite(row.id, !row.favorite) }) {
|
||||
Icon(
|
||||
if (row.favorite) Icons.Default.Star else Icons.Default.StarBorder,
|
||||
contentDescription = "Favorit",
|
||||
tint = if (row.favorite) {
|
||||
MaterialTheme.colorScheme.tertiary
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (uiState.exercises.isEmpty()) {
|
||||
item {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Text(
|
||||
if (uiState.query.isBlank() && uiState.selectedGroupId == null) {
|
||||
"Övningslistan är tom — hämtar från servern …"
|
||||
} else {
|
||||
"Inga övningar matchar — testa ett annat filter."
|
||||
},
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
androidx.compose.material3.FilledTonalButton(
|
||||
onClick = viewModel::refresh,
|
||||
modifier = Modifier.padding(top = 12.dp),
|
||||
) { Text("Uppdatera från servern") }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
package eu.brassepc.fitnessdroid.ui.picker
|
||||
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.lifecycle.viewmodel.initializer
|
||||
import androidx.lifecycle.viewmodel.viewModelFactory
|
||||
import eu.brassepc.fitnessdroid.data.GymRepository
|
||||
import eu.brassepc.fitnessdroid.data.local.CachedMuscle
|
||||
import eu.brassepc.fitnessdroid.data.local.CachedMuscleGroup
|
||||
import eu.brassepc.fitnessdroid.ui.appContainer
|
||||
import eu.brassepc.fitnessdroid.ui.common.MuscleIcons
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
data class ExerciseRow(
|
||||
val id: Int,
|
||||
val name: String,
|
||||
val subtitle: String,
|
||||
val badges: List<String>,
|
||||
val favorite: Boolean,
|
||||
val icon: ImageVector,
|
||||
)
|
||||
|
||||
data class PickerUiState(
|
||||
val query: String = "",
|
||||
val groups: List<CachedMuscleGroup> = emptyList(),
|
||||
val musclesInGroup: List<CachedMuscle> = emptyList(),
|
||||
val selectedGroupId: Int? = null,
|
||||
val selectedMuscleId: Int? = null,
|
||||
val exercises: List<ExerciseRow> = emptyList(),
|
||||
)
|
||||
|
||||
class PickerViewModel(private val repo: GymRepository) : ViewModel() {
|
||||
|
||||
private val query = MutableStateFlow("")
|
||||
private val selectedGroup = MutableStateFlow<Int?>(null)
|
||||
private val selectedMuscle = MutableStateFlow<Int?>(null)
|
||||
|
||||
init {
|
||||
// Tom cache (t.ex. första start eller misslyckad förra hämtning)?
|
||||
// Försök fylla den direkt när väljaren öppnas.
|
||||
viewModelScope.launch {
|
||||
if (repo.exerciseTypes.first().isEmpty()) repo.refreshAllAsync()
|
||||
}
|
||||
}
|
||||
|
||||
fun refresh() = repo.refreshAllAsync()
|
||||
|
||||
private data class Filters(val query: String, val groupId: Int?, val muscleId: Int?)
|
||||
|
||||
private val filters = combine(query, selectedGroup, selectedMuscle) { q, g, m -> Filters(q, g, m) }
|
||||
|
||||
val uiState = combine(
|
||||
repo.exerciseTypes,
|
||||
repo.muscleGroups,
|
||||
repo.muscles,
|
||||
filters,
|
||||
) { types, groups, muscles, f ->
|
||||
val musclesByGroup = muscles.groupBy { it.muscleGroupId }
|
||||
val groupByMuscleId = muscles.associate { it.id to it.muscleGroupId }
|
||||
val groupNameById = groups.associate { it.id to it.name }
|
||||
val groupIconKeyById = groups.associate { it.id to it.iconKey }
|
||||
|
||||
val filtered = types.filter { t ->
|
||||
val muscleIds = t.muscleIds.split(",").mapNotNull { it.trim().toIntOrNull() }
|
||||
val inGroup = f.groupId == null ||
|
||||
muscleIds.any { groupByMuscleId[it] == f.groupId }
|
||||
val inMuscle = f.muscleId == null || f.muscleId in muscleIds
|
||||
val matches = f.query.isBlank() || t.name.contains(f.query.trim(), ignoreCase = true)
|
||||
inGroup && inMuscle && matches
|
||||
}
|
||||
|
||||
PickerUiState(
|
||||
query = f.query,
|
||||
groups = groups,
|
||||
musclesInGroup = f.groupId?.let { musclesByGroup[it] }.orEmpty(),
|
||||
selectedGroupId = f.groupId,
|
||||
selectedMuscleId = f.muscleId,
|
||||
exercises = filtered.map { t ->
|
||||
val muscleIds = t.muscleIds.split(",").mapNotNull { it.trim().toIntOrNull() }
|
||||
val primaryGroupId = muscleIds.firstNotNullOfOrNull { groupByMuscleId[it] }
|
||||
val groupNames = muscleIds.mapNotNull { groupByMuscleId[it] }
|
||||
.distinct().mapNotNull { groupNameById[it] }
|
||||
ExerciseRow(
|
||||
id = t.id,
|
||||
name = t.name,
|
||||
subtitle = buildString {
|
||||
append(groupNames.joinToString(" · ").ifEmpty { "Övrigt" })
|
||||
t.lastWeight?.let { append(" · senast ${it.compact()} kg") }
|
||||
},
|
||||
badges = buildList {
|
||||
if (t.tracksWeight) add("KG")
|
||||
if (t.tracksReps) add("REPS")
|
||||
if (t.tracksDuration) add("TID")
|
||||
if (t.tracksDistance) add("M")
|
||||
},
|
||||
favorite = t.isFavorite,
|
||||
icon = MuscleIcons.resolve(
|
||||
primaryGroupId?.let { groupIconKeyById[it] },
|
||||
primaryGroupId?.let { groupNameById[it] },
|
||||
),
|
||||
)
|
||||
},
|
||||
)
|
||||
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), PickerUiState())
|
||||
|
||||
fun onQueryChange(value: String) { query.value = value }
|
||||
|
||||
fun selectGroup(id: Int?) {
|
||||
selectedGroup.value = id
|
||||
selectedMuscle.value = null
|
||||
}
|
||||
|
||||
fun selectMuscle(id: Int?) { selectedMuscle.value = id }
|
||||
|
||||
fun toggleFavorite(exerciseTypeId: Int, favorite: Boolean) =
|
||||
repo.toggleFavoriteExercise(exerciseTypeId, favorite)
|
||||
|
||||
fun pick(exerciseTypeId: Int, onDone: () -> Unit) {
|
||||
viewModelScope.launch {
|
||||
val sessionId = repo.activeSession.first()?.id ?: return@launch
|
||||
repo.addExercise(sessionId, exerciseTypeId)
|
||||
onDone()
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
val Factory: ViewModelProvider.Factory = viewModelFactory {
|
||||
initializer { PickerViewModel(appContainer().gymRepository) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun Double.compact(): String =
|
||||
if (this % 1.0 == 0.0) toInt().toString() else "%.1f".format(this)
|
||||
@@ -0,0 +1,422 @@
|
||||
package eu.brassepc.fitnessdroid.ui.profile
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
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.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.Logout
|
||||
import androidx.compose.material.icons.filled.MonitorWeight
|
||||
import androidx.compose.material.icons.filled.Settings
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
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.AuthRepository
|
||||
import eu.brassepc.fitnessdroid.data.BodyMeasurement
|
||||
import eu.brassepc.fitnessdroid.data.GymApi
|
||||
import eu.brassepc.fitnessdroid.data.GymRepository
|
||||
import eu.brassepc.fitnessdroid.data.Profile
|
||||
import eu.brassepc.fitnessdroid.ui.appContainer
|
||||
import eu.brassepc.fitnessdroid.ui.common.compact
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class ProfileViewModel(
|
||||
private val gymApi: GymApi,
|
||||
private val auth: AuthRepository,
|
||||
private val repo: GymRepository,
|
||||
) : ViewModel() {
|
||||
val profile = MutableStateFlow<Profile?>(null)
|
||||
val measurements = MutableStateFlow<List<BodyMeasurement>>(emptyList())
|
||||
val message = MutableStateFlow<String?>(null)
|
||||
|
||||
init { load() }
|
||||
|
||||
fun load() {
|
||||
viewModelScope.launch {
|
||||
runCatching { profile.value = gymApi.myProfile() }
|
||||
runCatching { measurements.value = repo.fetchBodyMeasurements().reversed() }
|
||||
}
|
||||
}
|
||||
|
||||
fun addWeight(weightKg: Double, dateIso: String?, muscle: Double?, fat: Double?, water: Double?) {
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
repo.addBodyMeasurement(weightKg, dateIso, muscle, fat, water)
|
||||
message.value = "Vikten sparad"
|
||||
load()
|
||||
} catch (e: Exception) {
|
||||
message.value = "Kunde inte spara — offline?"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun updateMeasurement(
|
||||
id: String, weightKg: Double, dateIso: String?,
|
||||
muscle: Double?, fat: Double?, water: Double?,
|
||||
) {
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
// -1 rensar fältet på servern om användaren tömt det
|
||||
repo.updateBodyMeasurement(
|
||||
id, weightKg, dateIso,
|
||||
muscle ?: -1.0, fat ?: -1.0, water ?: -1.0,
|
||||
)
|
||||
load()
|
||||
} catch (e: Exception) {
|
||||
message.value = "Kunde inte uppdatera — offline?"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun deleteMeasurement(id: String) {
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
repo.deleteBodyMeasurement(id)
|
||||
load()
|
||||
} catch (e: Exception) {
|
||||
message.value = "Kunde inte ta bort — offline?"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun logout() {
|
||||
viewModelScope.launch { auth.logout() }
|
||||
}
|
||||
|
||||
companion object {
|
||||
val Factory: ViewModelProvider.Factory = viewModelFactory {
|
||||
initializer {
|
||||
val c = appContainer()
|
||||
ProfileViewModel(c.gymApi, c.authRepository, c.gymRepository)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ProfileScreen(
|
||||
onOpenSettings: () -> Unit,
|
||||
viewModel: ProfileViewModel = viewModel(factory = ProfileViewModel.Factory),
|
||||
) {
|
||||
val profile by viewModel.profile.collectAsStateWithLifecycle()
|
||||
val measurements by viewModel.measurements.collectAsStateWithLifecycle()
|
||||
val message by viewModel.message.collectAsStateWithLifecycle()
|
||||
var showAddWeight by remember { mutableStateOf(false) }
|
||||
var editTarget by remember { mutableStateOf<BodyMeasurement?>(null) }
|
||||
|
||||
if (showAddWeight) {
|
||||
WeightDialog(
|
||||
title = "Uppdatera kroppsvikt",
|
||||
initial = profile?.bodyWeightKg,
|
||||
initialDate = java.time.LocalDate.now(),
|
||||
initialMuscle = null,
|
||||
initialFat = null,
|
||||
initialWater = null,
|
||||
onSave = { weight, dateIso, muscle, fat, water ->
|
||||
viewModel.addWeight(weight, dateIso, muscle, fat, water)
|
||||
showAddWeight = false
|
||||
},
|
||||
onDismiss = { showAddWeight = false },
|
||||
)
|
||||
}
|
||||
|
||||
editTarget?.let { m ->
|
||||
WeightDialog(
|
||||
title = "Rätta mätning",
|
||||
initial = m.weightKg,
|
||||
initialDate = runCatching { java.time.LocalDate.parse(m.date.take(10)) }
|
||||
.getOrElse { java.time.LocalDate.now() },
|
||||
initialMuscle = m.musclePercent,
|
||||
initialFat = m.fatPercent,
|
||||
initialWater = m.waterPercent,
|
||||
onSave = { weight, dateIso, muscle, fat, water ->
|
||||
viewModel.updateMeasurement(m.id, weight, dateIso, muscle, fat, water)
|
||||
editTarget = null
|
||||
},
|
||||
onDelete = {
|
||||
viewModel.deleteMeasurement(m.id)
|
||||
editTarget = null
|
||||
},
|
||||
onDismiss = { editTarget = null },
|
||||
)
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(14.dp),
|
||||
) {
|
||||
Text("Profil", style = MaterialTheme.typography.headlineSmall)
|
||||
|
||||
Card(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
ProfileRow("Användarnamn", profile?.username ?: "…")
|
||||
profile?.displayName?.let { ProfileRow("Namn", it) }
|
||||
profile?.email?.let { ProfileRow("E-post", it) }
|
||||
}
|
||||
}
|
||||
|
||||
Card(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(
|
||||
Icons.Default.MonitorWeight,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
Column(modifier = Modifier.weight(1f).padding(start = 10.dp)) {
|
||||
Text("Kroppsvikt", style = MaterialTheme.typography.titleSmall)
|
||||
Text(
|
||||
profile?.bodyWeightKg?.let { "${it.compact()} kg" } ?: "Inte satt",
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
)
|
||||
}
|
||||
Button(onClick = { showAddWeight = true }) { Text("Uppdatera") }
|
||||
}
|
||||
message?.let {
|
||||
Text(
|
||||
it,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
if (measurements.isNotEmpty()) {
|
||||
Text(
|
||||
"Historik (tryck för att rätta)",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
measurements.take(8).forEach { m ->
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { editTarget = m }
|
||||
.padding(vertical = 4.dp),
|
||||
) {
|
||||
Text(
|
||||
m.date.take(10),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
val composition = listOfNotNull(
|
||||
m.musclePercent?.let { "M ${it.compact()}%" },
|
||||
m.fatPercent?.let { "F ${it.compact()}%" },
|
||||
m.waterPercent?.let { "V ${it.compact()}%" },
|
||||
).joinToString(" · ")
|
||||
if (composition.isNotEmpty()) {
|
||||
Text(
|
||||
composition,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(end = 8.dp),
|
||||
)
|
||||
}
|
||||
Text(
|
||||
"${m.weightKg.compact()} kg",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = onOpenSettings),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(Icons.Default.Settings, contentDescription = null)
|
||||
Column(modifier = Modifier.weight(1f).padding(start = 12.dp)) {
|
||||
Text("Inställningar", style = MaterialTheme.typography.titleSmall)
|
||||
Text(
|
||||
"Vilotimer, viktsteg, stänger, inloggning",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
TextButton(onClick = viewModel::logout) {
|
||||
Icon(Icons.AutoMirrored.Filled.Logout, contentDescription = null)
|
||||
Text("Logga ut", modifier = Modifier.padding(start = 6.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(androidx.compose.material3.ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun WeightDialog(
|
||||
title: String,
|
||||
initial: Double?,
|
||||
initialDate: java.time.LocalDate,
|
||||
initialMuscle: Double?,
|
||||
initialFat: Double?,
|
||||
initialWater: Double?,
|
||||
onSave: (Double, String?, Double?, Double?, Double?) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
onDelete: (() -> Unit)? = null,
|
||||
) {
|
||||
fun fmt(v: Double?) = v?.let { if (it % 1.0 == 0.0) it.toInt().toString() else it.toString() } ?: ""
|
||||
var text by remember { mutableStateOf(fmt(initial)) }
|
||||
var muscle by remember { mutableStateOf(fmt(initialMuscle)) }
|
||||
var fat by remember { mutableStateOf(fmt(initialFat)) }
|
||||
var water by remember { mutableStateOf(fmt(initialWater)) }
|
||||
var date by remember { mutableStateOf(initialDate) }
|
||||
var showDatePicker by remember { mutableStateOf(false) }
|
||||
val parsed = text.trim().replace(',', '.').toDoubleOrNull()
|
||||
fun pct(s: String) = s.trim().replace(',', '.').toDoubleOrNull()
|
||||
|
||||
if (showDatePicker) {
|
||||
val dateState = androidx.compose.material3.rememberDatePickerState(
|
||||
initialSelectedDateMillis = date.atStartOfDay(java.time.ZoneOffset.UTC)
|
||||
.toInstant().toEpochMilli(),
|
||||
)
|
||||
androidx.compose.material3.DatePickerDialog(
|
||||
onDismissRequest = { showDatePicker = false },
|
||||
confirmButton = {
|
||||
TextButton(onClick = {
|
||||
dateState.selectedDateMillis?.let { picked ->
|
||||
date = java.time.Instant.ofEpochMilli(picked)
|
||||
.atZone(java.time.ZoneOffset.UTC).toLocalDate()
|
||||
}
|
||||
showDatePicker = false
|
||||
}) { Text("OK") }
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { showDatePicker = false }) { Text("Avbryt") }
|
||||
},
|
||||
) {
|
||||
androidx.compose.material3.DatePicker(state = dateState)
|
||||
}
|
||||
}
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(title) },
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
OutlinedTextField(
|
||||
value = text,
|
||||
onValueChange = { text = it },
|
||||
label = { Text("Vikt (kg)") },
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
androidx.compose.material3.FilledTonalButton(
|
||||
onClick = { showDatePicker = true },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text("Datum: $date")
|
||||
}
|
||||
Text(
|
||||
"Kroppssammansättning (valfritt)",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
OutlinedTextField(
|
||||
value = muscle,
|
||||
onValueChange = { muscle = it },
|
||||
label = { Text("Muskel %") },
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = fat,
|
||||
onValueChange = { fat = it },
|
||||
label = { Text("Fett %") },
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
OutlinedTextField(
|
||||
value = water,
|
||||
onValueChange = { water = it },
|
||||
label = { Text("Vatten %") },
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
onDelete?.let {
|
||||
TextButton(onClick = it) {
|
||||
Text("Ta bort mätningen", color = MaterialTheme.colorScheme.error)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
Button(
|
||||
onClick = {
|
||||
parsed?.let { weight ->
|
||||
// Mitt på dagen UTC så datumet inte glider en dag i någon tidszon
|
||||
val iso = date.atTime(12, 0).atOffset(java.time.ZoneOffset.UTC)
|
||||
.toInstant().toString()
|
||||
onSave(weight, iso, pct(muscle), pct(fat), pct(water))
|
||||
}
|
||||
},
|
||||
enabled = parsed != null && parsed > 0,
|
||||
) { Text("Spara") }
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) { Text("Avbryt") }
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ProfileRow(label: String, value: String) {
|
||||
Row(modifier = Modifier.fillMaxWidth()) {
|
||||
Text(
|
||||
label,
|
||||
modifier = Modifier.weight(1f),
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Text(value)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
package eu.brassepc.fitnessdroid.ui.session
|
||||
|
||||
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.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
/** Standardfärger på viktskivor (IPF-ish). Vikt i gram för exakt matematik. */
|
||||
private data class Plate(val grams: Int, val color: Color, val heightDp: Dp, val widthDp: Dp)
|
||||
|
||||
private val PLATES = listOf(
|
||||
Plate(25_000, Color(0xFFD32F2F), 88.dp, 13.dp), // 25 röd
|
||||
Plate(20_000, Color(0xFF1E64C8), 88.dp, 12.dp), // 20 blå
|
||||
Plate(15_000, Color(0xFFF9C513), 76.dp, 11.dp), // 15 gul
|
||||
Plate(10_000, Color(0xFF3E9B4F), 62.dp, 10.dp), // 10 grön
|
||||
Plate(5_000, Color(0xFFECECE4), 46.dp, 9.dp), // 5 vit
|
||||
Plate(2_500, Color(0xFF212121), 34.dp, 8.dp), // 2,5 svart
|
||||
Plate(1_250, Color(0xFF9E9E9E), 26.dp, 7.dp), // 1,25 grå
|
||||
)
|
||||
|
||||
private fun breakdown(perSideGrams: Int): Pair<List<Plate>, Int> {
|
||||
var rest = perSideGrams
|
||||
val result = mutableListOf<Plate>()
|
||||
for (plate in PLATES) {
|
||||
while (rest >= plate.grams) {
|
||||
result += plate
|
||||
rest -= plate.grams
|
||||
}
|
||||
}
|
||||
return result to rest
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun PlateCalculatorSheet(
|
||||
weightKg: Double,
|
||||
customBarWeights: List<Double>,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
var barKg by remember { mutableStateOf(20.0) }
|
||||
val barOptions = (listOf(20.0, 0.0) + customBarWeights).distinct()
|
||||
|
||||
val perSideGrams = (((weightKg - barKg) / 2.0) * 1000).toInt().coerceAtLeast(0)
|
||||
val (plates, restGrams) = breakdown(perSideGrams)
|
||||
|
||||
ModalBottomSheet(onDismissRequest = onDismiss) {
|
||||
Column(
|
||||
modifier = Modifier.padding(horizontal = 20.dp).padding(bottom = 28.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Text(
|
||||
"${weightKg.compact()} kg på stången",
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
)
|
||||
|
||||
androidx.compose.foundation.lazy.LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
items(barOptions.size) { i ->
|
||||
val option = barOptions[i]
|
||||
FilterChip(
|
||||
selected = barKg == option,
|
||||
onClick = { barKg = option },
|
||||
label = {
|
||||
Text(
|
||||
when (option) {
|
||||
0.0 -> "Utan stång"
|
||||
20.0 -> "Stång 20 kg"
|
||||
else -> "Stång ${option.compact()} kg"
|
||||
}
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (weightKg < barKg) {
|
||||
Text(
|
||||
"Vikten är lägre än stången — bara stången räcker (${barKg.compact()} kg).",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
} else {
|
||||
// Skivorna för ena sidan, störst innerst
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(96.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
// stångskaft in mot mitten
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.height(8.dp)
|
||||
.background(Color(0xFF757575), RoundedCornerShape(3.dp)),
|
||||
)
|
||||
// låskrage
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 2.dp)
|
||||
.size(width = 8.dp, height = 24.dp)
|
||||
.background(Color(0xFF8D8D8D), RoundedCornerShape(2.dp)),
|
||||
)
|
||||
plates.forEach { plate ->
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 1.5.dp)
|
||||
.size(width = plate.widthDp, height = plate.heightDp)
|
||||
.background(plate.color, RoundedCornerShape(3.dp)),
|
||||
)
|
||||
}
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.weight(0.3f)
|
||||
.height(8.dp)
|
||||
.background(Color(0xFF757575), RoundedCornerShape(3.dp)),
|
||||
)
|
||||
}
|
||||
|
||||
Text(
|
||||
buildString {
|
||||
append("Per sida: ")
|
||||
if (plates.isEmpty()) {
|
||||
append("inga skivor")
|
||||
} else {
|
||||
append(
|
||||
plates.groupBy { it.grams }.entries
|
||||
.sortedByDescending { it.key }
|
||||
.joinToString(" + ") { (grams, list) ->
|
||||
val kg = grams / 1000.0
|
||||
if (list.size > 1) "${list.size}×${kg.compact()}" else kg.compact()
|
||||
}
|
||||
)
|
||||
append(" kg")
|
||||
}
|
||||
},
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
|
||||
if (restGrams > 0) {
|
||||
val reachable = weightKg - (restGrams * 2 / 1000.0)
|
||||
Text(
|
||||
"Går inte att lägga exakt med standardskivor — närmast är " +
|
||||
"${reachable.compact()} kg (${(restGrams / 1000.0).compact()} kg/sida saknas).",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,245 @@
|
||||
package eu.brassepc.fitnessdroid.ui.session
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.lifecycle.viewmodel.initializer
|
||||
import androidx.lifecycle.viewmodel.viewModelFactory
|
||||
import eu.brassepc.fitnessdroid.data.AppSettings
|
||||
import eu.brassepc.fitnessdroid.data.GymRepository
|
||||
import eu.brassepc.fitnessdroid.data.RestState
|
||||
import eu.brassepc.fitnessdroid.data.RestTimerController
|
||||
import eu.brassepc.fitnessdroid.data.SettingsStore
|
||||
import eu.brassepc.fitnessdroid.data.local.CachedExerciseType
|
||||
import eu.brassepc.fitnessdroid.data.local.LocalSet
|
||||
import eu.brassepc.fitnessdroid.ui.appContainer
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/** Utkast till nästa set för en övning (styrs av steppers i UI:t). */
|
||||
data class SetDraft(
|
||||
val weight: Double? = null,
|
||||
val reps: Int? = null,
|
||||
val durationSeconds: Int? = null,
|
||||
val distanceMeters: Double? = null,
|
||||
val rpe: Double? = null,
|
||||
)
|
||||
|
||||
data class ExercisePage(
|
||||
val localId: Long,
|
||||
val type: CachedExerciseType?,
|
||||
val name: String,
|
||||
val doneSets: List<LocalSet>,
|
||||
val draft: SetDraft,
|
||||
val lastPerformanceText: String?,
|
||||
)
|
||||
|
||||
data class SessionUiState(
|
||||
val sessionId: Long? = null,
|
||||
val sessionName: String,
|
||||
val startedAtEpochMs: Long = 0,
|
||||
val pages: List<ExercisePage> = emptyList(),
|
||||
val pendingCount: Int = 0,
|
||||
val rest: RestState? = null,
|
||||
val settings: AppSettings = AppSettings(),
|
||||
val completedLocally: Boolean = false,
|
||||
) {
|
||||
companion object { val EMPTY = SessionUiState(sessionName = "Pass") }
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class SessionViewModel(
|
||||
private val repo: GymRepository,
|
||||
private val restTimer: RestTimerController,
|
||||
private val settingsStore: SettingsStore,
|
||||
) : ViewModel() {
|
||||
|
||||
/** exerciseLocalId → utkast som användaren pillat på */
|
||||
private val drafts = MutableStateFlow<Map<Long, SetDraft>>(emptyMap())
|
||||
|
||||
private val sessionFlow = repo.activeSession
|
||||
|
||||
private val bundleFlow = sessionFlow.flatMapLatest { session ->
|
||||
if (session == null) {
|
||||
flowOf(SessionUiState.EMPTY)
|
||||
} else {
|
||||
combine(
|
||||
repo.exercises(session.id),
|
||||
repo.setsForSession(session.id),
|
||||
repo.exerciseTypes,
|
||||
drafts,
|
||||
repo.pendingCount,
|
||||
) { exercises, sets, types, draftMap, pending ->
|
||||
val typeById = types.associateBy { it.id }
|
||||
val setsByExercise = sets.groupBy { it.exerciseId }
|
||||
SessionUiState(
|
||||
sessionId = session.id,
|
||||
sessionName = session.name ?: "Fritt pass",
|
||||
startedAtEpochMs = session.startedAtEpochMs,
|
||||
pendingCount = pending,
|
||||
pages = exercises.map { ex ->
|
||||
val type = typeById[ex.exerciseTypeId]
|
||||
val done = setsByExercise[ex.id].orEmpty()
|
||||
ExercisePage(
|
||||
localId = ex.id,
|
||||
type = type,
|
||||
name = type?.name ?: "Övning ${ex.order}",
|
||||
doneSets = done,
|
||||
draft = draftMap[ex.id] ?: initialDraft(type, done),
|
||||
lastPerformanceText = type?.lastWeight?.let { w ->
|
||||
"Senast: ${w.compact()} kg" +
|
||||
(type.lastReps?.let { " × $it" } ?: "")
|
||||
},
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val uiState = combine(
|
||||
bundleFlow,
|
||||
restTimer.state,
|
||||
settingsStore.settings,
|
||||
) { bundle, rest, settings ->
|
||||
bundle.copy(rest = rest, settings = settings)
|
||||
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), SessionUiState.EMPTY)
|
||||
|
||||
/** Förifyll utkastet: senaste setet i passet, annars cachens senaste prestation. */
|
||||
private fun initialDraft(type: CachedExerciseType?, done: List<LocalSet>): SetDraft {
|
||||
val last = done.lastOrNull()
|
||||
return SetDraft(
|
||||
weight = last?.weight ?: type?.lastWeight
|
||||
?: if (type?.tracksWeight == true) 20.0 else null,
|
||||
reps = last?.reps ?: type?.lastReps
|
||||
?: if (type?.tracksReps == true) 8 else null,
|
||||
durationSeconds = last?.durationSeconds ?: type?.lastDurationSeconds
|
||||
?: if (type?.tracksDuration == true) 30 else null,
|
||||
distanceMeters = last?.distanceMeters ?: type?.lastDistanceMeters
|
||||
?: if (type?.tracksDistance == true) 1000.0 else null,
|
||||
rpe = last?.rpe,
|
||||
)
|
||||
}
|
||||
|
||||
fun updateDraft(exerciseId: Long, transform: (SetDraft) -> SetDraft) {
|
||||
val page = uiState.value.pages.firstOrNull { it.localId == exerciseId } ?: return
|
||||
drafts.value = drafts.value + (exerciseId to transform(page.draft))
|
||||
}
|
||||
|
||||
fun logSet(exerciseId: Long) {
|
||||
val page = uiState.value.pages.firstOrNull { it.localId == exerciseId } ?: return
|
||||
val d = page.draft
|
||||
viewModelScope.launch {
|
||||
repo.logSet(
|
||||
exerciseId = exerciseId,
|
||||
reps = if (page.type?.tracksReps != false) d.reps else null,
|
||||
weight = if (page.type?.tracksWeight != false) d.weight else null,
|
||||
distanceMeters = if (page.type?.tracksDistance == true) d.distanceMeters else null,
|
||||
durationSeconds = if (page.type?.tracksDuration == true) d.durationSeconds else null,
|
||||
rpe = d.rpe,
|
||||
isWarmup = false,
|
||||
)
|
||||
// API-tillägget defaultRestSeconds per övning; annars appens standard.
|
||||
restTimer.start(page.type?.defaultRestSeconds)
|
||||
}
|
||||
}
|
||||
|
||||
fun adjustRest(delta: Int) = restTimer.adjust(delta)
|
||||
fun skipRest() = restTimer.dismiss()
|
||||
|
||||
/** Rätta ett redan loggat set (synkas som update, eller bärs av väntande add). */
|
||||
fun saveEditedSet(setId: Long, draft: SetDraft) {
|
||||
val set = uiState.value.pages.flatMap { it.doneSets }.firstOrNull { it.id == setId } ?: return
|
||||
viewModelScope.launch {
|
||||
repo.updateSet(
|
||||
set.copy(
|
||||
weight = draft.weight,
|
||||
reps = draft.reps,
|
||||
durationSeconds = draft.durationSeconds,
|
||||
distanceMeters = draft.distanceMeters,
|
||||
rpe = draft.rpe,
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun deleteSet(setId: Long) {
|
||||
viewModelScope.launch { repo.deleteSet(setId) }
|
||||
}
|
||||
|
||||
/* ---------- Övningshistorik-arket ---------- */
|
||||
|
||||
sealed interface HistorySheetState {
|
||||
data class Loading(val exerciseName: String) : HistorySheetState
|
||||
data class Error(val exerciseName: String) : HistorySheetState
|
||||
data class Ready(
|
||||
val exerciseName: String,
|
||||
val history: eu.brassepc.fitnessdroid.data.ExerciseHistory,
|
||||
) : HistorySheetState
|
||||
}
|
||||
|
||||
private val _historySheet = MutableStateFlow<HistorySheetState?>(null)
|
||||
val historySheet = _historySheet.asStateFlow()
|
||||
|
||||
fun openExerciseHistory(page: ExercisePage) {
|
||||
val typeId = page.type?.id ?: return
|
||||
_historySheet.value = HistorySheetState.Loading(page.name)
|
||||
viewModelScope.launch {
|
||||
_historySheet.value = try {
|
||||
HistorySheetState.Ready(page.name, repo.fetchExerciseHistory(typeId))
|
||||
} catch (e: Exception) {
|
||||
HistorySheetState.Error(page.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun closeExerciseHistory() {
|
||||
_historySheet.value = null
|
||||
}
|
||||
|
||||
/** Kroppsvikt för kaloriuppskattningen (cachad från profilen). */
|
||||
val bodyWeightKg = MutableStateFlow<Double?>(null)
|
||||
|
||||
/** Personbästa (typeId, reps) → vikt, för 🏆-markeringen vid avslut. */
|
||||
val personalBests = MutableStateFlow<Map<Pair<Int, Int>, Double>>(emptyMap())
|
||||
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
bodyWeightKg.value = repo.bodyWeightKg()
|
||||
personalBests.value = repo.personalBests()
|
||||
}
|
||||
}
|
||||
|
||||
fun completeSession(
|
||||
durationSecondsOverride: Int?,
|
||||
editedStartEpochMs: Long?,
|
||||
onDone: () -> Unit,
|
||||
) {
|
||||
viewModelScope.launch {
|
||||
val id = uiState.value.sessionId ?: sessionFlow.first()?.id ?: return@launch
|
||||
restTimer.dismiss()
|
||||
repo.completeSession(id, durationSecondsOverride, editedStartEpochMs)
|
||||
onDone()
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
val Factory: ViewModelProvider.Factory = viewModelFactory {
|
||||
initializer {
|
||||
val c = appContainer()
|
||||
SessionViewModel(c.gymRepository, c.restTimer, c.settingsStore)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal fun Double.compact(): String =
|
||||
if (this % 1.0 == 0.0) toInt().toString() else "%.1f".format(this)
|
||||
@@ -0,0 +1,351 @@
|
||||
package eu.brassepc.fitnessdroid.ui.settings
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
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.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.selection.selectable
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.FilledTonalButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.RadioButton
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
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.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.AppSettings
|
||||
import eu.brassepc.fitnessdroid.data.RestAlert
|
||||
import eu.brassepc.fitnessdroid.data.RestTimerStyle
|
||||
import eu.brassepc.fitnessdroid.data.SettingsStore
|
||||
import eu.brassepc.fitnessdroid.ui.appContainer
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
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 addCustomBar(value: Double) = viewModelScope.launch { store.addCustomBarWeight(value) }
|
||||
fun removeCustomBar(value: Double) = viewModelScope.launch { store.removeCustomBarWeight(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 {
|
||||
val c = appContainer()
|
||||
SettingsViewModel(c.settingsStore, c.tokenStore, c.credentialStore)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun SettingsScreen(
|
||||
onBack: () -> Unit,
|
||||
viewModel: SettingsViewModel = viewModel(factory = SettingsViewModel.Factory),
|
||||
) {
|
||||
val settings by viewModel.settings.collectAsStateWithLifecycle()
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text("Inställningar") },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Tillbaka")
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
) { padding ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding)
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
SettingsCard(title = "Vilotimerns utseende") {
|
||||
RadioRow(
|
||||
label = "Helskärm",
|
||||
description = "Timern tar över skärmen mellan seten",
|
||||
selected = settings.restTimerStyle == RestTimerStyle.FULLSCREEN,
|
||||
onClick = { viewModel.setStyle(RestTimerStyle.FULLSCREEN) },
|
||||
)
|
||||
RadioRow(
|
||||
label = "Liten banner",
|
||||
description = "Diskret rad längst ner på pass-sidan",
|
||||
selected = settings.restTimerStyle == RestTimerStyle.BANNER,
|
||||
onClick = { viewModel.setStyle(RestTimerStyle.BANNER) },
|
||||
)
|
||||
}
|
||||
|
||||
SettingsCard(title = "När vilan är klar") {
|
||||
RadioRow(
|
||||
label = "Larmljud",
|
||||
description = "Spelar notissignalen",
|
||||
selected = settings.restAlert == RestAlert.SOUND,
|
||||
onClick = { viewModel.setAlert(RestAlert.SOUND) },
|
||||
)
|
||||
RadioRow(
|
||||
label = "Vibration",
|
||||
description = "Telefonen vibrerar",
|
||||
selected = settings.restAlert == RestAlert.VIBRATE,
|
||||
onClick = { viewModel.setAlert(RestAlert.VIBRATE) },
|
||||
)
|
||||
RadioRow(
|
||||
label = "Bara visuellt",
|
||||
description = "Timern byter utseende, inget ljud eller surr",
|
||||
selected = settings.restAlert == RestAlert.VISUAL,
|
||||
onClick = { viewModel.setAlert(RestAlert.VISUAL) },
|
||||
)
|
||||
RadioRow(
|
||||
label = "Ingenting",
|
||||
description = "Timern försvinner tyst",
|
||||
selected = settings.restAlert == RestAlert.NONE,
|
||||
onClick = { viewModel.setAlert(RestAlert.NONE) },
|
||||
)
|
||||
}
|
||||
|
||||
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 = "Skivkalkylatorn — egna stänger") {
|
||||
androidx.compose.foundation.lazy.LazyRow(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 16.dp),
|
||||
) {
|
||||
items(settings.customBarWeights.size) { i ->
|
||||
val bar = settings.customBarWeights[i]
|
||||
androidx.compose.material3.InputChip(
|
||||
selected = false,
|
||||
onClick = { viewModel.removeCustomBar(bar) },
|
||||
label = {
|
||||
Text(
|
||||
if (bar % 1.0 == 0.0) "${bar.toInt()} kg" else "$bar kg".replace('.', ',')
|
||||
)
|
||||
},
|
||||
trailingIcon = {
|
||||
androidx.compose.material3.Icon(
|
||||
Icons.Default.Close,
|
||||
contentDescription = "Ta bort",
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
var newBar by androidx.compose.runtime.remember {
|
||||
androidx.compose.runtime.mutableStateOf("")
|
||||
}
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
androidx.compose.material3.OutlinedTextField(
|
||||
value = newBar,
|
||||
onValueChange = { newBar = it },
|
||||
label = { Text("Ny stångvikt (t.ex. 13,7)") },
|
||||
singleLine = true,
|
||||
keyboardOptions = androidx.compose.foundation.text.KeyboardOptions(
|
||||
keyboardType = androidx.compose.ui.text.input.KeyboardType.Decimal,
|
||||
),
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
FilledTonalButton(onClick = {
|
||||
newBar.trim().replace(',', '.').toDoubleOrNull()?.let {
|
||||
viewModel.addCustomBar(it)
|
||||
newBar = ""
|
||||
}
|
||||
}) { Text("Lägg till") }
|
||||
}
|
||||
Text(
|
||||
"Snabbvalen 20 kg och utan stång finns alltid i kalkylatorn.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(start = 16.dp, end = 16.dp, bottom = 12.dp),
|
||||
)
|
||||
}
|
||||
|
||||
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. " +
|
||||
"Aktiveras vid nästa inloggning.",
|
||||
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
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
FilledTonalButton(onClick = { viewModel.adjustRestSeconds(-15) }) { Text("−15 s") }
|
||||
Text(
|
||||
"${settings.defaultRestSeconds} s",
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = Modifier.weight(1f),
|
||||
textAlign = androidx.compose.ui.text.style.TextAlign.Center,
|
||||
)
|
||||
FilledTonalButton(onClick = { viewModel.adjustRestSeconds(15) }) { Text("+15 s") }
|
||||
}
|
||||
Text(
|
||||
"Övningar kan få egen vilotid via API:t — då gäller den istället.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(start = 16.dp, end = 16.dp, bottom = 12.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SettingsCard(title: String, content: @Composable () -> Unit) {
|
||||
Card(modifier = Modifier.fillMaxWidth()) {
|
||||
Column {
|
||||
Text(
|
||||
title,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
modifier = Modifier.padding(start = 16.dp, top = 14.dp, bottom = 4.dp),
|
||||
)
|
||||
content()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RadioRow(label: String, description: String, selected: Boolean, onClick: () -> Unit) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.selectable(selected = selected, onClick = onClick)
|
||||
.padding(horizontal = 16.dp, vertical = 6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
RadioButton(selected = selected, onClick = onClick)
|
||||
Column(modifier = Modifier.padding(start = 8.dp)) {
|
||||
Text(label, style = MaterialTheme.typography.bodyLarge)
|
||||
Text(
|
||||
description,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package eu.brassepc.fitnessdroid.ui.stats
|
||||
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.Path
|
||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||
import androidx.compose.ui.unit.dp
|
||||
import eu.brassepc.fitnessdroid.data.BodyMeasurement
|
||||
import java.time.OffsetDateTime
|
||||
|
||||
data class CompositionSeries(val label: String, val color: Color, val values: List<Double?>)
|
||||
|
||||
/**
|
||||
* Flerlinjes-graf för kroppssammansättning över tid. Varje serie (muskel/
|
||||
* fett/vatten) ritas som egen linje; null-värden hoppas över (spanGaps).
|
||||
* Delar tidsordningen med [WeightGraph].
|
||||
*/
|
||||
@Composable
|
||||
fun CompositionGraph(
|
||||
measurements: List<BodyMeasurement>,
|
||||
series: List<CompositionSeries>,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val order = measurements.mapIndexedNotNull { i, m ->
|
||||
runCatching { OffsetDateTime.parse(m.date).toInstant().toEpochMilli() }.getOrNull()?.let { i to it }
|
||||
}.sortedBy { it.second }.map { it.first }
|
||||
if (order.size < 2) return
|
||||
|
||||
val allValues = series.flatMap { it.values }.filterNotNull()
|
||||
if (allValues.isEmpty()) return
|
||||
val lo = allValues.min() * 0.9
|
||||
val hi = allValues.max() * 1.1
|
||||
val span = (hi - lo).takeIf { it > 0 } ?: 1.0
|
||||
|
||||
Canvas(modifier = modifier.fillMaxWidth().height(160.dp)) {
|
||||
fun x(idx: Int) = idx / (order.size - 1).toFloat() * size.width
|
||||
fun y(v: Double) = (size.height - ((v - lo) / span).toFloat() * size.height)
|
||||
|
||||
series.forEach { s ->
|
||||
val path = Path()
|
||||
var started = false
|
||||
order.forEachIndexed { plotIdx, measurementIdx ->
|
||||
val v = s.values.getOrNull(measurementIdx) ?: return@forEachIndexed
|
||||
val px = x(plotIdx)
|
||||
val py = y(v)
|
||||
if (!started) { path.moveTo(px, py); started = true } else path.lineTo(px, py)
|
||||
drawCircle(s.color, radius = 5f, center = Offset(px, py))
|
||||
}
|
||||
if (started) drawPath(path, color = s.color, style = Stroke(width = 4f))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,513 @@
|
||||
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.layout.width
|
||||
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.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
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
|
||||
|
||||
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"),
|
||||
PeriodOption("all", "Totalt"),
|
||||
)
|
||||
|
||||
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)
|
||||
val bodyMeasurements = MutableStateFlow<List<eu.brassepc.fitnessdroid.data.BodyMeasurement>>(emptyList())
|
||||
|
||||
init {
|
||||
load()
|
||||
refreshMeasurements()
|
||||
}
|
||||
|
||||
fun refreshMeasurements() {
|
||||
viewModelScope.launch {
|
||||
runCatching { bodyMeasurements.value = repo.fetchBodyMeasurements() }
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
|
||||
// Hämta om mätningarna varje gång fliken öppnas — ny vikt kan ha
|
||||
// lagts in via profilen sedan sist.
|
||||
androidx.compose.runtime.LaunchedEffect(Unit) {
|
||||
viewModel.refreshMeasurements()
|
||||
}
|
||||
|
||||
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 {
|
||||
androidx.compose.foundation.lazy.LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
items(PERIODS, key = { it.id }) { 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 " + when (period) {
|
||||
"week", "month" -> "dag"
|
||||
"halfyear" -> "vecka"
|
||||
else -> "månad"
|
||||
},
|
||||
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),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
val measurements by viewModel.bodyMeasurements.collectAsStateWithLifecycle()
|
||||
if (measurements.size >= 2) {
|
||||
Card(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(modifier = Modifier.padding(14.dp)) {
|
||||
Row {
|
||||
Text(
|
||||
"Kroppsvikt",
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
Text(
|
||||
"${measurements.last().weightKg.compact()} kg nu",
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
WeightGraph(
|
||||
measurements = measurements,
|
||||
lineColor = MaterialTheme.colorScheme.primary,
|
||||
trendColor = MaterialTheme.colorScheme.tertiary,
|
||||
pointColor = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 10.dp),
|
||||
)
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 6.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
Text(
|
||||
measurements.first().date.take(10),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Text(
|
||||
"— mjuk linje · - - trend",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Text(
|
||||
measurements.last().date.take(10),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
val measurements by viewModel.bodyMeasurements.collectAsStateWithLifecycle()
|
||||
val hasComposition = measurements.any {
|
||||
it.musclePercent != null || it.fatPercent != null || it.waterPercent != null
|
||||
}
|
||||
if (measurements.size >= 2 && hasComposition) {
|
||||
CompositionCard(measurements)
|
||||
}
|
||||
}
|
||||
|
||||
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 data class CompPart(val label: String, val value: (eu.brassepc.fitnessdroid.data.BodyMeasurement) -> Double?)
|
||||
|
||||
@Composable
|
||||
private fun CompositionCard(measurements: List<eu.brassepc.fitnessdroid.data.BodyMeasurement>) {
|
||||
var showKg by remember { mutableStateOf(false) }
|
||||
val latest = measurements.last()
|
||||
|
||||
val muscle = MaterialTheme.colorScheme.primary
|
||||
val fat = MaterialTheme.colorScheme.tertiary
|
||||
val water = MaterialTheme.colorScheme.secondary
|
||||
|
||||
val parts = listOf(
|
||||
Triple("Muskler", muscle, CompPart("Muskler") { it.musclePercent }),
|
||||
Triple("Fett", fat, CompPart("Fett") { it.fatPercent }),
|
||||
Triple("Vatten", water, CompPart("Vatten") { it.waterPercent }),
|
||||
).filter { (_, _, p) -> measurements.any { p.value(it) != null } }
|
||||
|
||||
fun kgOf(m: eu.brassepc.fitnessdroid.data.BodyMeasurement, pct: Double?): Double? =
|
||||
if (pct == null) null else m.weightKg * pct / 100.0
|
||||
|
||||
Card(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(modifier = Modifier.padding(14.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
"Sammansättning",
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
// %/kg-växel
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
FilterChip(
|
||||
selected = !showKg,
|
||||
onClick = { showKg = false },
|
||||
label = { Text("%") },
|
||||
)
|
||||
FilterChip(
|
||||
selected = showKg,
|
||||
onClick = { showKg = true },
|
||||
label = { Text("kg") },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Senaste värden: % + omräknat till kg
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 10.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
parts.forEach { (label, color, part) ->
|
||||
val pct = part.value(latest)
|
||||
val kg = kgOf(latest, pct)
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(label, style = MaterialTheme.typography.labelSmall, color = color)
|
||||
Text(
|
||||
pct?.let { "${it.compact()}%" } ?: "–",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
Text(
|
||||
kg?.let { "≈ ${it.compact()} kg" } ?: "",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
CompositionGraph(
|
||||
measurements = measurements,
|
||||
series = parts.map { (label, color, part) ->
|
||||
CompositionSeries(
|
||||
label = label,
|
||||
color = color,
|
||||
values = measurements.map { m ->
|
||||
val pct = part.value(m)
|
||||
if (showKg) kgOf(m, pct) else pct
|
||||
},
|
||||
)
|
||||
},
|
||||
modifier = Modifier.padding(top = 12.dp),
|
||||
)
|
||||
Text(
|
||||
if (showKg) "Faktisk vikt per del (kg)" else "Andel av kroppsvikten (%)",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 6.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
|
||||
val listState = androidx.compose.foundation.lazy.rememberLazyListState()
|
||||
|
||||
// Börja längst till höger — senaste perioden är den intressanta.
|
||||
androidx.compose.runtime.LaunchedEffect(stats.trend.size) {
|
||||
if (stats.trend.isNotEmpty()) listState.scrollToItem(stats.trend.size - 1)
|
||||
}
|
||||
|
||||
androidx.compose.foundation.lazy.LazyRow(
|
||||
state = listState,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(112.dp)
|
||||
.padding(top = 10.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
verticalAlignment = Alignment.Bottom,
|
||||
) {
|
||||
items(stats.trend.size) { i ->
|
||||
val bucket = stats.trend[i]
|
||||
Column(
|
||||
modifier = Modifier.width(38.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
if (bucket.volumeKg > 0) {
|
||||
Text(
|
||||
if (bucket.volumeKg >= 1000) {
|
||||
"${(bucket.volumeKg / 1000).toInt()}t"
|
||||
} else {
|
||||
"${bucket.volumeKg.toInt()}"
|
||||
},
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(((bucket.volumeKg / max) * 64).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,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
package eu.brassepc.fitnessdroid.ui.stats
|
||||
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.Path
|
||||
import androidx.compose.ui.graphics.PathEffect
|
||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||
import androidx.compose.ui.unit.dp
|
||||
import eu.brassepc.fitnessdroid.data.BodyMeasurement
|
||||
import java.time.OffsetDateTime
|
||||
|
||||
/**
|
||||
* Kroppsvikt över tid: råpunkter, glidande medelvärde (mjuk linje) och
|
||||
* linjär trendlinje (streckad).
|
||||
*/
|
||||
@Composable
|
||||
fun WeightGraph(
|
||||
measurements: List<BodyMeasurement>,
|
||||
lineColor: Color,
|
||||
trendColor: Color,
|
||||
pointColor: Color,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
var points = measurements.mapNotNull { m ->
|
||||
runCatching {
|
||||
OffsetDateTime.parse(m.date).toInstant().toEpochMilli().toFloat() to m.weightKg.toFloat()
|
||||
}.getOrNull()
|
||||
}.sortedBy { it.first }
|
||||
if (points.size < 2) return
|
||||
|
||||
// Alla mätningar inom samma dygn? Sprid dem jämnt på index istället,
|
||||
// annars kollapsar hela grafen till en punkt.
|
||||
if (points.last().first - points.first().first < 86_400_000f) {
|
||||
points = points.mapIndexed { i, (_, w) -> i.toFloat() to w }
|
||||
}
|
||||
|
||||
val minX = points.first().first
|
||||
val maxX = points.last().first
|
||||
val minW = points.minOf { it.second }
|
||||
val maxW = points.maxOf { it.second }
|
||||
val padW = ((maxW - minW).takeIf { it > 0f } ?: 1f) * 0.15f
|
||||
val loW = minW - padW
|
||||
val hiW = maxW + padW
|
||||
|
||||
// Glidande medelvärde (fönster 5)
|
||||
val smooth = points.mapIndexed { i, _ ->
|
||||
val from = (i - 2).coerceAtLeast(0)
|
||||
val to = (i + 2).coerceAtMost(points.lastIndex)
|
||||
val slice = points.subList(from, to + 1)
|
||||
points[i].first to slice.map { it.second }.average().toFloat()
|
||||
}
|
||||
|
||||
// Linjär regression för trendlinjen
|
||||
val n = points.size.toFloat()
|
||||
val meanX = points.map { it.first }.average().toFloat()
|
||||
val meanY = points.map { it.second }.average().toFloat()
|
||||
val denominator = points.sumOf { ((it.first - meanX) * (it.first - meanX)).toDouble() }
|
||||
val slope = if (denominator == 0.0) 0f else {
|
||||
(points.sumOf { ((it.first - meanX) * (it.second - meanY)).toDouble() } / denominator).toFloat()
|
||||
}
|
||||
val intercept = meanY - slope * meanX
|
||||
|
||||
Canvas(modifier = modifier.fillMaxWidth().height(160.dp)) {
|
||||
fun x(v: Float) = (v - minX) / (maxX - minX).coerceAtLeast(1f) * size.width
|
||||
fun y(w: Float) = size.height - (w - loW) / (hiW - loW) * size.height
|
||||
|
||||
// Trendlinje (streckad)
|
||||
drawLine(
|
||||
color = trendColor,
|
||||
start = Offset(0f, y(slope * minX + intercept)),
|
||||
end = Offset(size.width, y(slope * maxX + intercept)),
|
||||
strokeWidth = 3f,
|
||||
pathEffect = PathEffect.dashPathEffect(floatArrayOf(14f, 10f)),
|
||||
)
|
||||
|
||||
// Mjuk linje (glidande medelvärde)
|
||||
val path = Path()
|
||||
smooth.forEachIndexed { i, (px, pw) ->
|
||||
if (i == 0) path.moveTo(x(px), y(pw)) else path.lineTo(x(px), y(pw))
|
||||
}
|
||||
drawPath(path, color = lineColor, style = Stroke(width = 5f))
|
||||
|
||||
// Råpunkter
|
||||
points.forEach { (px, pw) ->
|
||||
drawCircle(color = pointColor, radius = 6f, center = Offset(x(px), y(pw)))
|
||||
}
|
||||
}
|
||||
}
|
||||
4
app/src/main/res/xml/file_paths.xml
Normal file
4
app/src/main/res/xml/file_paths.xml
Normal file
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<paths>
|
||||
<cache-path name="updates" path="updates/" />
|
||||
</paths>
|
||||
@@ -3,4 +3,5 @@ plugins {
|
||||
alias(libs.plugins.kotlin.android) apply false
|
||||
alias(libs.plugins.kotlin.compose) apply false
|
||||
alias(libs.plugins.kotlin.serialization) apply false
|
||||
alias(libs.plugins.ksp) apply false
|
||||
}
|
||||
|
||||
645
doc/design-forslag-m2.html
Normal file
645
doc/design-forslag-m2.html
Normal file
@@ -0,0 +1,645 @@
|
||||
<title>FitnessDroid — designförslag</title>
|
||||
<meta name="description" content="Passpilot-konceptet: mockups för passläge, hemskärm, övningsväljare, Android-ytor, kroppskarta och API-förslag.">
|
||||
<style>
|
||||
:root{
|
||||
--ground:#0c0f0b; --panel:#131812; --panel-hi:#1a201a; --line:#263026;
|
||||
--ink:#e4e7de; --dim:#96a093; --accent:#8fd694; --accent-deep:#2e4a33;
|
||||
--m3-bg:#101410; --m3-surf:#1a211a; --m3-surf2:#232b23; --m3-surf3:#2b342b;
|
||||
--m3-pri:#8fd694; --m3-onpri:#08300f; --m3-on:#e2e5dc; --m3-dim:#a9b2a5;
|
||||
--m3-outline:#3e483d; --m3-seccont:#33503a; --m3-onsec:#cfeacf;
|
||||
--heat0:#233026; --heat1:#3d5c42; --heat2:#5f8f60; --heat3:#8fd694; --heat4:#d3f0c9;
|
||||
}
|
||||
*{box-sizing:border-box;margin:0}
|
||||
body{background:var(--ground);color:var(--ink);
|
||||
font:16px/1.55 Roboto,system-ui,-apple-system,"Segoe UI",sans-serif;
|
||||
-webkit-font-smoothing:antialiased;padding:0 20px 96px}
|
||||
.wrap{max-width:1180px;margin:0 auto}
|
||||
a{color:var(--accent)}
|
||||
|
||||
header.top{padding:64px 0 24px;border-bottom:1px solid var(--line);margin-bottom:8px}
|
||||
.eyebrow{font-size:11px;letter-spacing:.16em;text-transform:uppercase;color:var(--accent);font-weight:600}
|
||||
h1{font-size:clamp(28px,4.5vw,44px);font-weight:800;letter-spacing:-.02em;text-wrap:balance;margin:10px 0 12px}
|
||||
.lede{color:var(--dim);max-width:66ch}
|
||||
section{padding:52px 0 8px}
|
||||
.secnum{font:600 12px/1 ui-monospace,monospace;color:var(--dim);letter-spacing:.1em}
|
||||
h2{font-size:26px;font-weight:750;letter-spacing:-.01em;margin:8px 0 6px;text-wrap:balance}
|
||||
.secintro{color:var(--dim);max-width:64ch;margin-bottom:28px}
|
||||
h3{font-size:17px;font-weight:700;margin:0 0 4px}
|
||||
|
||||
/* koncept-kort */
|
||||
.modes{display:flex;gap:14px;flex-wrap:wrap;margin-top:22px}
|
||||
.mode{flex:1 1 240px;background:var(--panel);border:1px solid var(--line);border-radius:16px;padding:18px}
|
||||
.mode b{display:block;font-size:15px;margin-bottom:5px}
|
||||
.mode span{color:var(--dim);font-size:14px}
|
||||
.mode .k{font:700 11px/1 ui-monospace,monospace;color:var(--accent);letter-spacing:.12em;display:block;margin-bottom:10px}
|
||||
|
||||
/* telefonram */
|
||||
.phone{width:320px;background:#000;border:1px solid #2c342c;border-radius:30px;
|
||||
padding:9px;flex:0 0 auto;box-shadow:0 24px 60px -30px rgba(0,0,0,.8)}
|
||||
.screen{background:var(--m3-bg);border-radius:22px;overflow:hidden;display:flex;
|
||||
flex-direction:column;height:640px;font-size:13.5px;color:var(--m3-on)}
|
||||
.statusbar{display:flex;justify-content:space-between;align-items:center;
|
||||
padding:10px 18px 4px;font-size:11px;color:var(--m3-dim)}
|
||||
.sb-ic{display:flex;gap:5px;align-items:center}
|
||||
.content{flex:1;overflow:hidden;padding:6px 14px 0;display:flex;flex-direction:column;gap:11px}
|
||||
.gesture{height:18px;display:flex;align-items:center;justify-content:center}
|
||||
.gesture i{display:block;width:96px;height:4px;border-radius:2px;background:#3a423a}
|
||||
|
||||
/* m3-bitar */
|
||||
.m3-greet{padding:6px 4px 0}
|
||||
.m3-greet b{font-size:21px;font-weight:700;display:block}
|
||||
.m3-greet span{color:var(--m3-dim);font-size:12.5px}
|
||||
.card{background:var(--m3-surf);border-radius:16px;padding:13px 14px}
|
||||
.card.tonal{background:var(--m3-surf2)}
|
||||
.rowline{display:flex;align-items:center;gap:10px}
|
||||
.rowline .grow{flex:1;min-width:0}
|
||||
.btn-filled{background:var(--m3-pri);color:var(--m3-onpri);border-radius:999px;
|
||||
padding:13px 18px;font-weight:700;font-size:14.5px;text-align:center;
|
||||
display:flex;align-items:center;justify-content:center;gap:8px}
|
||||
.seclabel{display:flex;justify-content:space-between;align-items:baseline;padding:2px 4px 0}
|
||||
.seclabel b{font-size:13.5px;font-weight:700}
|
||||
.seclabel a{font-size:11.5px;color:var(--m3-pri);text-decoration:none;font-weight:600}
|
||||
.hscroll{display:flex;gap:10px;overflow:hidden}
|
||||
.favcard{background:var(--m3-surf2);border-radius:16px;padding:12px;min-width:150px;flex:0 0 auto}
|
||||
.favcard .nm{font-weight:700;font-size:13.5px;display:flex;gap:6px;align-items:center}
|
||||
.favcard .muscles{color:var(--m3-dim);font-size:10.5px;margin:3px 0 8px}
|
||||
.favcard .go{margin-top:6px;background:var(--m3-pri);color:var(--m3-onpri);border-radius:999px;
|
||||
font-size:11.5px;font-weight:700;padding:6px 0;text-align:center}
|
||||
.star{color:#ffd977;font-size:12px}
|
||||
.listrow{display:flex;gap:11px;align-items:center;padding:9px 4px;border-bottom:1px solid #202820}
|
||||
.listrow:last-child{border-bottom:0}
|
||||
.listrow .t{font-weight:600;font-size:13px}
|
||||
.listrow .s{color:var(--m3-dim);font-size:11px}
|
||||
.mchip{width:34px;height:34px;border-radius:50%;background:var(--m3-surf3);
|
||||
display:flex;align-items:center;justify-content:center;flex:0 0 auto}
|
||||
.mchip svg{width:19px;height:19px;stroke:var(--m3-pri);fill:none;stroke-width:1.7;
|
||||
stroke-linecap:round;stroke-linejoin:round}
|
||||
.badge{font-size:9.5px;font-weight:700;background:var(--m3-surf3);color:var(--m3-dim);
|
||||
border-radius:5px;padding:2px 5px;letter-spacing:.03em}
|
||||
.searchbar{background:var(--m3-surf2);border-radius:999px;padding:10px 15px;color:var(--m3-dim);
|
||||
display:flex;gap:9px;align-items:center;font-size:13px}
|
||||
.chiprow{display:flex;gap:7px;overflow:hidden;padding:1px}
|
||||
.chip{border:1px solid var(--m3-outline);border-radius:9px;padding:6px 11px;font-size:11.5px;
|
||||
font-weight:600;color:var(--m3-dim);display:inline-flex;gap:6px;align-items:center;flex:0 0 auto}
|
||||
.chip svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:1.8;
|
||||
stroke-linecap:round;stroke-linejoin:round}
|
||||
.chip.sel{background:var(--m3-seccont);color:var(--m3-onsec);border-color:transparent}
|
||||
.chip.small{padding:4px 10px;font-size:10.5px;border-radius:999px}
|
||||
.topbar-menu{display:flex;justify-content:space-between;align-items:center;padding:2px 4px}
|
||||
.topbar-menu b{font-size:16px}
|
||||
|
||||
/* passläget */
|
||||
.sess-top{display:flex;justify-content:space-between;align-items:center;padding:2px 4px}
|
||||
.sess-top b{font-size:15.5px}
|
||||
.timerchip{background:var(--m3-seccont);color:var(--m3-onsec);border-radius:999px;
|
||||
padding:4px 11px;font:700 12px/1.4 ui-monospace,monospace}
|
||||
.pagerdots{display:flex;gap:5px;align-items:center;padding:0 4px}
|
||||
.pagerdots i{height:4px;border-radius:2px;background:var(--m3-surf3);flex:1}
|
||||
.pagerdots i.done{background:var(--m3-pri);opacity:.45}
|
||||
.pagerdots i.cur{background:var(--m3-pri)}
|
||||
.exhead{padding:2px 4px}
|
||||
.exhead b{font-size:19px;font-weight:750;display:block}
|
||||
.exhead span{color:var(--m3-dim);font-size:11.5px}
|
||||
.setdone{display:flex;align-items:center;gap:10px;padding:7px 10px;border-radius:11px;
|
||||
background:var(--m3-surf);color:var(--m3-dim);font-size:12.5px}
|
||||
.setdone .ck{color:var(--m3-pri);font-weight:800}
|
||||
.setactive{background:var(--m3-surf2);border-radius:16px;padding:13px;display:flex;
|
||||
flex-direction:column;gap:11px}
|
||||
.steppers{display:flex;gap:9px}
|
||||
.stepper{flex:1;background:var(--m3-bg);border-radius:13px;padding:8px 9px;
|
||||
display:flex;align-items:center;justify-content:space-between}
|
||||
.stepper .pm{width:30px;height:30px;border-radius:50%;background:var(--m3-surf3);
|
||||
color:var(--m3-on);font:700 16px/30px sans-serif;text-align:center}
|
||||
.stepper .val{text-align:center}
|
||||
.stepper .val b{font-size:17px;font-variant-numeric:tabular-nums}
|
||||
.stepper .val span{display:block;font-size:9.5px;color:var(--m3-dim);letter-spacing:.08em;text-transform:uppercase}
|
||||
.rpe{display:flex;gap:6px;align-items:center;font-size:10.5px;color:var(--m3-dim)}
|
||||
.rpe i{font-style:normal;border:1px solid var(--m3-outline);border-radius:7px;
|
||||
padding:3px 9px;font-weight:700;font-size:11px}
|
||||
.rpe i.sel{background:var(--m3-seccont);color:var(--m3-onsec);border-color:transparent}
|
||||
.nextup{display:flex;align-items:center;gap:9px;color:var(--m3-dim);font-size:11.5px;padding:0 4px}
|
||||
|
||||
/* vilo-overlay */
|
||||
.rest{flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:16px}
|
||||
.ring{width:190px;height:190px;border-radius:50%;position:relative;
|
||||
background:conic-gradient(var(--m3-pri) 74%,var(--m3-surf3) 0)}
|
||||
.ring::after{content:"";position:absolute;inset:12px;border-radius:50%;background:var(--m3-bg)}
|
||||
.ring .mid{position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;
|
||||
justify-content:center;z-index:1}
|
||||
.ring .mid b{font:800 40px/1 ui-monospace,monospace;font-variant-numeric:tabular-nums}
|
||||
.ring .mid span{color:var(--m3-dim);font-size:11px;letter-spacing:.1em;text-transform:uppercase}
|
||||
.restbtns{display:flex;gap:10px}
|
||||
.restbtns .btn-tonal{background:var(--m3-surf3);color:var(--m3-on);border-radius:999px;
|
||||
padding:9px 16px;font-weight:700;font-size:12.5px}
|
||||
.restnext{color:var(--m3-dim);font-size:12px;text-align:center}
|
||||
.restnext b{color:var(--m3-on)}
|
||||
|
||||
/* strips: minispelare + notis */
|
||||
.strips{display:flex;flex-direction:column;gap:18px;min-width:280px;flex:1 1 340px}
|
||||
.striplabel{font-size:11px;letter-spacing:.14em;text-transform:uppercase;color:var(--dim);font-weight:700;margin-bottom:7px}
|
||||
.stripbar{background:var(--m3-surf2);border:1px solid var(--line);border-radius:14px;
|
||||
padding:10px 13px;display:flex;align-items:center;gap:11px;max-width:340px}
|
||||
.stripbar .grow{flex:1;min-width:0}
|
||||
.stripbar .t{font-weight:700;font-size:12.5px;color:var(--m3-on)}
|
||||
.stripbar .s{font-size:10.5px;color:var(--m3-dim)}
|
||||
.stripbar .act{color:var(--m3-pri);font-weight:800;font-size:11px}
|
||||
.noti{background:#181d18;border:1px solid var(--line);border-radius:18px;padding:13px 15px;max-width:340px}
|
||||
.noti .approw{display:flex;gap:7px;align-items:center;font-size:10px;color:var(--m3-dim);margin-bottom:7px}
|
||||
.noti .approw i{width:13px;height:13px;border-radius:4px;background:var(--m3-pri);display:inline-block}
|
||||
.noti b{font-size:13px;color:var(--m3-on)}
|
||||
.noti p{font-size:12px;color:var(--m3-dim);margin:2px 0 9px}
|
||||
.noti .actions{display:flex;gap:16px;font-weight:700;font-size:12px;color:var(--m3-pri)}
|
||||
|
||||
/* skivkalkylator */
|
||||
.sheet{background:var(--m3-surf);border-radius:20px 20px 14px 14px;padding:16px;max-width:340px;border:1px solid var(--line)}
|
||||
.sheet .grab{width:36px;height:4px;border-radius:2px;background:var(--m3-outline);margin:0 auto 12px}
|
||||
.sheet h4{font-size:14px;margin-bottom:2px}
|
||||
.sheet .sub{color:var(--m3-dim);font-size:11.5px;margin-bottom:13px}
|
||||
.bar{display:flex;align-items:center;height:64px;margin:4px 0 12px}
|
||||
.bar .shaft{height:7px;background:#5a635a;flex:1;border-radius:3px}
|
||||
.bar .collar{width:9px;height:22px;background:#788078;border-radius:2px}
|
||||
.plate{border-radius:4px;margin:0 1.5px;box-shadow:inset 0 0 0 1.5px rgba(0,0,0,.35)}
|
||||
.p20{width:13px;height:62px;background:#3f6fb5}
|
||||
.p10{width:11px;height:46px;background:#4d9b57}
|
||||
.p5{width:10px;height:34px;background:#d7d7cf}
|
||||
.p25{width:9px;height:26px;background:#c05252}
|
||||
.sheet .plates-txt{font-size:12.5px;color:var(--m3-dim)}
|
||||
.sheet .plates-txt b{color:var(--m3-on)}
|
||||
|
||||
/* widget */
|
||||
.widget{background:linear-gradient(145deg,#1c231c,#141a14);border:1px solid var(--line);
|
||||
border-radius:22px;padding:14px 16px;max-width:340px}
|
||||
.widget .wt{display:flex;justify-content:space-between;align-items:center;margin-bottom:11px}
|
||||
.widget .wt b{font-size:12.5px;color:var(--m3-on)}
|
||||
.widget .wt span{font-size:10px;color:var(--m3-dim)}
|
||||
.widget .wbtns{display:flex;gap:8px}
|
||||
.widget .wb{flex:1;border-radius:12px;padding:10px 8px;font-size:11.5px;font-weight:700;text-align:center}
|
||||
.widget .wb.pri{background:var(--m3-pri);color:var(--m3-onpri)}
|
||||
.widget .wb.ton{background:var(--m3-surf3);color:var(--m3-on)}
|
||||
|
||||
/* kroppskarta */
|
||||
.bodymaps{display:flex;gap:34px;flex-wrap:wrap;align-items:flex-start}
|
||||
.bodycard{background:var(--panel);border:1px solid var(--line);border-radius:18px;padding:22px 26px;display:flex;gap:26px}
|
||||
.bodycard figure{text-align:center}
|
||||
.bodycard figcaption{font-size:11px;color:var(--dim);margin-top:8px;letter-spacing:.08em;text-transform:uppercase;font-weight:700}
|
||||
.heatlegend{display:flex;align-items:center;gap:8px;font-size:11.5px;color:var(--dim);margin-top:16px}
|
||||
.heatlegend i{width:22px;height:10px;border-radius:3px;display:inline-block}
|
||||
|
||||
/* layout runt telefoner */
|
||||
.phones{display:flex;gap:26px;flex-wrap:wrap;align-items:flex-start}
|
||||
.duo{display:flex;gap:40px;flex-wrap:wrap;align-items:flex-start}
|
||||
.notes{flex:1 1 340px;min-width:280px;display:flex;flex-direction:column;gap:16px}
|
||||
.note{display:flex;gap:13px;align-items:flex-start}
|
||||
.note .dot{width:24px;height:24px;border-radius:50%;background:var(--accent);
|
||||
color:#0a2e10;font:700 12.5px/24px ui-monospace,monospace;text-align:center;flex:0 0 auto}
|
||||
.note p{color:var(--dim);font-size:14.5px}
|
||||
.note b{color:var(--ink)}
|
||||
.phcaption{font-size:11px;letter-spacing:.12em;text-transform:uppercase;color:var(--dim);
|
||||
font-weight:700;text-align:center;margin-top:12px}
|
||||
|
||||
table{border-collapse:collapse;width:100%;font-size:13.5px;margin-top:22px}
|
||||
th,td{text-align:left;padding:9px 12px;border-bottom:1px solid var(--line);vertical-align:top}
|
||||
th{color:var(--dim);font-size:11px;text-transform:uppercase;letter-spacing:.1em}
|
||||
td:first-child{font-weight:600;white-space:nowrap;color:var(--ink)}
|
||||
td{color:var(--dim)}
|
||||
.tablewrap{overflow-x:auto}
|
||||
.pill-sm{display:inline-block;font-size:10px;font-weight:800;border-radius:5px;padding:2px 7px;
|
||||
letter-spacing:.06em}
|
||||
.pill-sm.s{background:var(--accent-deep);color:var(--accent)}
|
||||
.pill-sm.m{background:#4a3f22;color:#e8c96e}
|
||||
|
||||
.decide{background:var(--panel);border:1px solid var(--line);border-radius:16px;
|
||||
padding:22px 24px;margin-top:44px}
|
||||
.decide h2{font-size:19px;margin:0 0 10px}
|
||||
.decide ol{margin:0;padding-left:20px;color:var(--dim);display:flex;flex-direction:column;gap:8px;font-size:14.5px}
|
||||
.decide b{color:var(--ink)}
|
||||
</style>
|
||||
|
||||
<div class="wrap">
|
||||
<header class="top">
|
||||
<div class="eyebrow">FitnessDroid · Milstolpe 2 · Designförslag v2</div>
|
||||
<h1>En passpilot i fickan — inte webben i mindre format</h1>
|
||||
<p class="lede">Nytt tänk: appen byggs runt <b>själva gympasset</b>, inte runt flikar.
|
||||
Mellan seten har du svettiga händer och 90 sekunder — allt i passläget är därför
|
||||
stora tryckytor, förifyllda värden och en vilotimer som sköter sig själv, även på
|
||||
låsskärmen. Runt det: en hemskärm som startar pass på ett tryck, och Android-ytor
|
||||
som webben aldrig kan nå.</p>
|
||||
<div class="modes">
|
||||
<div class="mode"><span class="k">LÄGE 1 · STARTA</span><b>Hemskärmen</b>
|
||||
<span>Favoritpass, fritt pass, widget och appgenvägar — max två tryck till första setet.</span></div>
|
||||
<div class="mode"><span class="k">LÄGE 2 · KÖR</span><b>Passläget</b>
|
||||
<span>En övning i taget, set loggas med ett tryck, vilotimern tar över — appen coachar, du lyfter.</span></div>
|
||||
<div class="mode"><span class="k">LÄGE 3 · FÖLJ UPP</span><b>Statistik & PB</b>
|
||||
<span>Kroppskarta som visar var veckans volym hamnat, trender och PB-historik.</span></div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- ============ 1. PASSLÄGET ============ -->
|
||||
<section>
|
||||
<div class="secnum">1 / 6</div>
|
||||
<h2>Passläget — en övning i taget, ett tryck per set</h2>
|
||||
<p class="secintro">Hela skärmen ägnas åt övningen du står vid. Svep i sidled mellan
|
||||
övningarna i passet. Värdena är alltid förifyllda från förra passet plus
|
||||
progressionsregeln — stämmer allt trycker du bara på gröna knappen.</p>
|
||||
|
||||
<div class="duo">
|
||||
<div>
|
||||
<div class="phone"><div class="screen">
|
||||
<div class="statusbar"><span>19:24</span><span class="sb-ic">▲ ▮</span></div>
|
||||
<div class="content">
|
||||
<div class="sess-top">
|
||||
<span style="display:flex;align-items:center;gap:9px">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" style="stroke:var(--m3-on);fill:none;stroke-width:1.8;stroke-linecap:round"><path d="M15 5l-7 7 7 7"/></svg>
|
||||
<b>Push A</b></span>
|
||||
<span class="timerchip">24:31</span>
|
||||
</div>
|
||||
<div class="pagerdots"><i class="done"></i><i class="done"></i><i class="cur"></i><i></i><i></i><i></i><i></i><i></i></div>
|
||||
<div class="exhead"><b>Bänkpress</b>
|
||||
<span>Övning 3 av 8 · Bröst, triceps · Förra passet: 4×8 @ 77,5 kg</span></div>
|
||||
|
||||
<div class="setdone"><span class="ck">✓</span>Set 1 · 80,0 kg × 8 · RPE 7</div>
|
||||
<div class="setdone"><span class="ck">✓</span>Set 2 · 80,0 kg × 8 · RPE 8</div>
|
||||
|
||||
<div class="setactive">
|
||||
<div style="font-size:11px;color:var(--m3-dim);font-weight:700;letter-spacing:.08em">SET 3 AV 4</div>
|
||||
<div class="steppers">
|
||||
<div class="stepper"><span class="pm">−</span>
|
||||
<span class="val"><b>80,0</b><span>kg · ±2,5</span></span><span class="pm">+</span></div>
|
||||
<div class="stepper"><span class="pm">−</span>
|
||||
<span class="val"><b>8</b><span>reps</span></span><span class="pm">+</span></div>
|
||||
</div>
|
||||
<div class="rpe">RPE <i>7</i><i class="sel">8</i><i>9</i><i>10</i></div>
|
||||
<div class="btn-filled">✓ Logga set — vilan startar</div>
|
||||
</div>
|
||||
|
||||
<div class="nextup">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" style="stroke:currentColor;fill:none;stroke-width:1.8;stroke-linecap:round"><path d="M5 12h14m-6-6 6 6-6 6"/></svg>
|
||||
Nästa: Lutande hantelpress · 3×10 @ 28 kg</div>
|
||||
</div>
|
||||
<div class="gesture"><i></i></div>
|
||||
</div></div>
|
||||
<div class="phcaption">Passläget · aktivt set</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="phone"><div class="screen">
|
||||
<div class="statusbar"><span>19:26</span><span class="sb-ic">▲ ▮</span></div>
|
||||
<div class="content">
|
||||
<div class="sess-top"><b style="font-size:14px">Vila</b><span class="timerchip">Push A · 26:04</span></div>
|
||||
<div class="rest">
|
||||
<div class="ring"><div class="mid"><b>1:07</b><span>av 1:30</span></div></div>
|
||||
<div class="restbtns">
|
||||
<span class="btn-tonal">−15 s</span>
|
||||
<span class="btn-tonal">+15 s</span>
|
||||
<span class="btn-tonal">Hoppa över</span>
|
||||
</div>
|
||||
<div class="restnext">Härnäst: <b>Set 4 av 4 · 80,0 kg × 8</b><br>
|
||||
Sista setet på bänken — sen lutande hantelpress</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="gesture"><i></i></div>
|
||||
</div></div>
|
||||
<div class="phcaption">Vilan tar över skärmen</div>
|
||||
</div>
|
||||
|
||||
<div class="notes">
|
||||
<div class="note"><div class="dot">1</div><p><b>Ett tryck per set.</b> Vikt och
|
||||
reps förifylls från förra passet + progressionsregeln (API:t har redan
|
||||
<i>lastExercisePerformance</i>). Stämmer det: tryck ✓. Annars ±-knappar —
|
||||
aldrig tangentbord för standardfallet.</p></div>
|
||||
<div class="note"><div class="dot">2</div><p><b>Vilan sköter sig själv.</b> Loggat
|
||||
set → timern startar, skärmen växlar till ringen, telefonen vibrerar när det
|
||||
är dags. Längden hämtas per övning (litet API-tillägg, se sist).</p></div>
|
||||
<div class="note"><div class="dot">3</div><p><b>Svep = nästa övning.</b> Ingen
|
||||
lista att scrolla i mitt i passet. Prickraden överst visar var i passet du är;
|
||||
långtryck öppnar överblicken där du kan ändra ordning eller lägga till övningar.</p></div>
|
||||
<div class="note"><div class="dot">4</div><p><b>PB firas direkt</b> — slår du
|
||||
ett personbästa på ett set får du konfetti och en 🏆-notis i samma sekund,
|
||||
inte när passet är slut.</p></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="margin-top:38px" class="duo">
|
||||
<div class="strips">
|
||||
<div><div class="striplabel">Minispelare — passet följer med i hela appen (som Spotify)</div>
|
||||
<div class="stripbar">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" style="fill:var(--m3-pri)"><path d="M8 5v14l11-7z"/></svg>
|
||||
<div class="grow"><div class="t">Push A · Bänkpress</div><div class="s">Vila 0:47 kvar · set 4 av 4</div></div>
|
||||
<span class="act">ÖPPNA</span>
|
||||
</div></div>
|
||||
<div><div class="striplabel">Notis på låsskärmen — timern utan att ta upp telefonen ur fickan</div>
|
||||
<div class="noti">
|
||||
<div class="approw"><i></i> FitnessDroid · pågår</div>
|
||||
<b>Vila 0:47 — sen set 4 av 4</b>
|
||||
<p>Bänkpress 80,0 kg × 8 · Push A 26:04</p>
|
||||
<div class="actions"><span>+15 s</span><span>LOGGA SET</span><span>PAUSA PASS</span></div>
|
||||
</div></div>
|
||||
</div>
|
||||
<div class="notes" style="justify-content:center">
|
||||
<div class="note"><div class="dot">5</div><p><b>Lämna passläget utan att tappa
|
||||
passet.</b> Kollar du statistik mitt i passet ligger minispelaren kvar ovanför
|
||||
navigeringen. Notisen med timer-åtgärder gör att telefonen kan ligga kvar i
|
||||
fickan mellan seten — <b>det här är appens största övertag över webben.</b></p></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ============ 2. HEMSKÄRM ============ -->
|
||||
<section>
|
||||
<div class="secnum">2 / 6</div>
|
||||
<h2>Hemskärmen — startmotorn</h2>
|
||||
<p class="secintro">Öppnar du appen med ett pass igång åker du rakt in i passläget.
|
||||
Annars: starta på ett eller två tryck.</p>
|
||||
|
||||
<div class="duo">
|
||||
<div>
|
||||
<div class="phone"><div class="screen">
|
||||
<div class="statusbar"><span>19:24</span><span class="sb-ic">▲ ▮</span></div>
|
||||
<div class="content">
|
||||
<div class="m3-greet"><b>Tjena Björn</b><span>Vecka 30 · 2 pass · 3 veckors streak 🔥</span></div>
|
||||
<div class="btn-filled" style="padding:15px 18px"><svg width="18" height="18" viewBox="0 0 24 24" style="fill:currentColor"><path d="M8 5v14l11-7z"/></svg> Starta fritt pass</div>
|
||||
<div class="seclabel"><b>Favoritpass</b><a>Alla mallar</a></div>
|
||||
<div class="hscroll">
|
||||
<div class="favcard">
|
||||
<div class="nm"><span class="star">★</span>Push A</div>
|
||||
<div class="muscles">Bröst · Axlar · Triceps</div>
|
||||
<div class="go">▶ Starta</div>
|
||||
</div>
|
||||
<div class="favcard">
|
||||
<div class="nm"><span class="star">★</span>Ben & core</div>
|
||||
<div class="muscles">Ben · Mage</div>
|
||||
<div class="go">▶ Starta</div>
|
||||
</div>
|
||||
<div class="favcard">
|
||||
<div class="nm"><span class="star">★</span>Rygg</div>
|
||||
<div class="muscles">Rygg · Biceps</div>
|
||||
<div class="go">▶ Starta</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card tonal rowline">
|
||||
<div class="mchip"><svg viewBox="0 0 24 24"><path d="M4 19V9m6 10V5m6 14v-7"/></svg></div>
|
||||
<div class="grow"><div style="font-weight:700;font-size:12.5px">Veckans muskler</div>
|
||||
<div style="font-size:10.5px;color:var(--m3-dim)">Mest bröst & triceps — benen släpar efter</div></div>
|
||||
<svg width="30" height="44" viewBox="0 0 30 46" aria-hidden="true">
|
||||
<circle cx="15" cy="5" r="4" fill="#3d5c42"/>
|
||||
<rect x="7" y="11" width="16" height="15" rx="5" fill="#8fd694"/>
|
||||
<rect x="2" y="12" width="4.5" height="12" rx="2" fill="#5f8f60"/>
|
||||
<rect x="23.5" y="12" width="4.5" height="12" rx="2" fill="#5f8f60"/>
|
||||
<rect x="8" y="27" width="6" height="16" rx="2.6" fill="#233026"/>
|
||||
<rect x="16" y="27" width="6" height="16" rx="2.6" fill="#233026"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="seclabel"><b>Senaste pass</b><a>Historik</a></div>
|
||||
<div>
|
||||
<div class="listrow"><div class="mchip"><svg viewBox="0 0 24 24"><path d="M4 12h16M7 8v8m10-8v8M4 10v4m16-4v4"/></svg></div>
|
||||
<div class="grow"><div class="t">Push A</div><div class="s">mån 21/7 · 52 min · 4 890 kg</div></div>
|
||||
<span class="badge">2 PB</span></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="gesture"><i></i></div>
|
||||
</div></div>
|
||||
<div class="phcaption">Hemskärmen · inget pass igång</div>
|
||||
</div>
|
||||
|
||||
<div class="notes">
|
||||
<div class="note"><div class="dot">1</div><p><b>Fritt pass = ett tryck.</b>
|
||||
Passet skapas direkt och övningsväljaren öppnas. Namn kan sättas efteråt —
|
||||
inga formulär mellan dig och första setet.</p></div>
|
||||
<div class="note"><div class="dot">2</div><p><b>Favoritpass = två tryck.</b>
|
||||
API:ts favoritpass + mallar, med auto-progressionen förifylld.</p></div>
|
||||
<div class="note"><div class="dot">3</div><p><b>Veckans muskler</b> — en liten
|
||||
kroppskarta som teaser för statistiken, och en knuff om vad som är underjobbat.
|
||||
Trycker du på kortet öppnas hela kartan (sektion 5).</p></div>
|
||||
<div class="note"><div class="dot">4</div><p><b>Pågår ett pass öppnas appen
|
||||
direkt i passläget</b> — hemskärmen visas bara när inget är igång. Därför
|
||||
behövs inget "fortsätt"-kort: appen minns åt dig.</p></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ============ 3. ÖVNINGSVÄLJAREN ============ -->
|
||||
<section>
|
||||
<div class="secnum">3 / 6</div>
|
||||
<h2>Övningsväljaren — sök, muskelfilter, favoriter först</h2>
|
||||
<p class="secintro">Muskelgrupper som chips med ikoner; vald grupp fäller ut sina
|
||||
muskler som en andra rad. Brickorna (KG/REPS/TID/M) kommer från övningens
|
||||
tracks-flaggor och styr sedan set-loggningens fält.</p>
|
||||
|
||||
<div class="duo">
|
||||
<div>
|
||||
<div class="phone"><div class="screen">
|
||||
<div class="statusbar"><span>19:24</span><span class="sb-ic">▲ ▮</span></div>
|
||||
<div class="content">
|
||||
<div class="topbar-menu"><span style="display:flex;align-items:center;gap:10px">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" style="stroke:var(--m3-on);fill:none;stroke-width:1.8;stroke-linecap:round"><path d="M15 5l-7 7 7 7"/></svg>
|
||||
<b>Lägg till övning</b></span></div>
|
||||
<div class="searchbar"><svg width="15" height="15" viewBox="0 0 24 24" style="stroke:currentColor;fill:none;stroke-width:1.8"><circle cx="11" cy="11" r="6"/><path d="m20 20-4-4"/></svg> Sök övning …</div>
|
||||
<div class="chiprow">
|
||||
<span class="chip">Alla</span>
|
||||
<span class="chip"><svg viewBox="0 0 24 24"><path d="M6 4h12l-1.5 8a4.5 4.5 0 0 1-9 0z"/><path d="M12 12v8"/></svg>Bröst</span>
|
||||
<span class="chip sel"><svg viewBox="0 0 24 24"><path d="M7 4v7l5 9 5-9V4"/><path d="M7 8h10"/></svg>Rygg</span>
|
||||
<span class="chip"><svg viewBox="0 0 24 24"><path d="M9 4v6l-2.5 9h4L12 13l1.5 6h4L15 10V4"/></svg>Ben</span>
|
||||
<span class="chip"><svg viewBox="0 0 24 24"><path d="M4 12a8 8 0 0 1 16 0"/><circle cx="4" cy="14" r="2"/><circle cx="20" cy="14" r="2"/></svg>Axlar</span>
|
||||
<span class="chip"><svg viewBox="0 0 24 24"><path d="M5 18l5-5m0 0 5-8 4 3-5 7-4-2z"/><circle cx="5" cy="19" r="2"/></svg>Armar</span>
|
||||
</div>
|
||||
<div class="chiprow">
|
||||
<span class="chip small sel">Alla i Rygg</span>
|
||||
<span class="chip small">Latsen</span>
|
||||
<span class="chip small">Trapezius</span>
|
||||
<span class="chip small">Ländrygg</span>
|
||||
</div>
|
||||
<div style="flex:1">
|
||||
<div class="listrow"><div class="mchip"><svg viewBox="0 0 24 24"><path d="M4 12h16M7 8v8m10-8v8M4 10v4m16-4v4"/></svg></div>
|
||||
<div class="grow"><div class="t">Marklyft <span class="star">★</span></div><div class="s">Rygg · Ben · senast 77,5 kg</div></div>
|
||||
<span class="badge">KG</span><span class="badge">REPS</span></div>
|
||||
<div class="listrow"><div class="mchip"><svg viewBox="0 0 24 24"><path d="M7 4v7l5 9 5-9V4"/><path d="M7 8h10"/></svg></div>
|
||||
<div class="grow"><div class="t">Latsdrag <span class="star">★</span></div><div class="s">Latsen · senast 65 kg</div></div>
|
||||
<span class="badge">KG</span><span class="badge">REPS</span></div>
|
||||
<div class="listrow"><div class="mchip"><svg viewBox="0 0 24 24"><path d="M7 4v7l5 9 5-9V4"/><path d="M7 8h10"/></svg></div>
|
||||
<div class="grow"><div class="t">Skivstångsrodd</div><div class="s">Rygg · Biceps</div></div>
|
||||
<span class="badge">KG</span><span class="badge">REPS</span></div>
|
||||
<div class="listrow"><div class="mchip"><svg viewBox="0 0 24 24"><path d="M4 6h16M8 6v4a4 4 0 0 0 8 0V6"/><path d="M12 14v6"/></svg></div>
|
||||
<div class="grow"><div class="t">Pullups</div><div class="s">Latsen · kroppsvikt</div></div>
|
||||
<span class="badge">REPS</span></div>
|
||||
<div class="listrow"><div class="mchip"><svg viewBox="0 0 24 24"><path d="M7 4v7l5 9 5-9V4"/><path d="M7 8h10"/></svg></div>
|
||||
<div class="grow"><div class="t">Ryggresning</div><div class="s">Ländrygg</div></div>
|
||||
<span class="badge">REPS</span><span class="badge">TID</span></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="gesture"><i></i></div>
|
||||
</div></div>
|
||||
<div class="phcaption">Övningsväljaren · Rygg valt</div>
|
||||
</div>
|
||||
|
||||
<div class="notes">
|
||||
<div class="note"><div class="dot">★</div><p><b>Favoriter alltid överst</b>
|
||||
(API:ts favoritövningar), med senaste vikten som förhandsinfo så du slipper
|
||||
minnas var du låg.</p></div>
|
||||
<div class="note"><div class="dot">◐</div><p><b>Ikoner istället för foton:</b>
|
||||
API:t saknar övningsbilder, så appen buntar vektorikoner per muskelgrupp —
|
||||
offline, blixtsnabba, temafärgade. Vill du hellre ha en <b>kroppskarta där
|
||||
vald muskel tänds</b> som filter är det samma dataunderlag — säg till vilken
|
||||
du föredrar (kartan finns i sektion 5).</p></div>
|
||||
<div class="note"><div class="dot">+</div><p><b>Ny övning på plats:</b> hittas
|
||||
inte övningen visas "Skapa övningen ‹sökordet›" längst ned — grunddatan
|
||||
behöver aldrig besökas mitt i ett pass.</p></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ============ 4. ANDROID-YTOR ============ -->
|
||||
<section>
|
||||
<div class="secnum">4 / 6</div>
|
||||
<h2>Utanför appen — noll tryck innan gymmet</h2>
|
||||
<p class="secintro">Det webben aldrig kan: starta och styra pass utan att ens öppna appen.</p>
|
||||
<div class="duo">
|
||||
<div class="strips">
|
||||
<div><div class="striplabel">Hemskärms-widget (4×2)</div>
|
||||
<div class="widget">
|
||||
<div class="wt"><b>FitnessDroid</b><span>3 v streak · 2 pass denna vecka</span></div>
|
||||
<div class="wbtns">
|
||||
<span class="wb pri">▶ Push A</span>
|
||||
<span class="wb ton">▶ Ben & core</span>
|
||||
<span class="wb ton">Fritt pass</span>
|
||||
</div>
|
||||
</div></div>
|
||||
<div><div class="striplabel">Långtryck på appikonen — genvägar</div>
|
||||
<div class="noti" style="padding:9px 0;max-width:250px">
|
||||
<div style="font-size:12.5px;font-weight:700;color:var(--m3-on);padding:6px 16px">▶ Starta Push A</div>
|
||||
<div style="font-size:12.5px;font-weight:700;color:var(--m3-on);padding:6px 16px;border-top:1px solid var(--line)">▶ Starta fritt pass</div>
|
||||
<div style="font-size:12.5px;font-weight:700;color:var(--m3-on);padding:6px 16px;border-top:1px solid var(--line)">📊 Veckans statistik</div>
|
||||
</div></div>
|
||||
</div>
|
||||
<div class="strips">
|
||||
<div><div class="striplabel">Skivkalkylatorn — tryck på vikten i passläget</div>
|
||||
<div class="sheet">
|
||||
<div class="grab"></div>
|
||||
<h4>80,0 kg på stången</h4>
|
||||
<div class="sub">Stång 20 kg · per sida:</div>
|
||||
<div class="bar">
|
||||
<span class="plate p5"></span><span class="plate p25"></span><span class="plate p10"></span><span class="plate p20"></span>
|
||||
<span class="collar"></span><span class="shaft"></span><span class="collar"></span>
|
||||
<span class="plate p20"></span><span class="plate p10"></span><span class="plate p25"></span><span class="plate p5"></span>
|
||||
</div>
|
||||
<div class="plates-txt">Per sida: <b>20 + 10 + 2,5 + 5</b> — dina skivor
|
||||
ställs in en gång under Profil. Räknar även ut närmsta möjliga vikt.</div>
|
||||
</div></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ============ 5. KROPPSKARTAN ============ -->
|
||||
<section>
|
||||
<div class="secnum">5 / 6</div>
|
||||
<h2>Kroppskartan — var hamnade veckans volym?</h2>
|
||||
<p class="secintro">Statistikens signaturvy: en stiliserad figur där varje muskelgrupp
|
||||
färgas efter träningsvolymen i perioden (datat finns redan i API:ts workoutStats).
|
||||
Samma karta kan återanvändas som muskelväljare i övningsfiltret om du hellre vill
|
||||
det än chips.</p>
|
||||
|
||||
<div class="bodymaps">
|
||||
<div class="bodycard">
|
||||
<figure>
|
||||
<svg width="150" height="290" viewBox="0 0 100 195" role="img" aria-label="Kroppskarta framsida">
|
||||
<circle cx="50" cy="12" r="9" fill="#233026"/>
|
||||
<rect x="43" y="22" width="14" height="7" rx="3" fill="#233026"/>
|
||||
<ellipse cx="35" cy="36" rx="9" ry="7" fill="#5f8f60"/>
|
||||
<ellipse cx="65" cy="36" rx="9" ry="7" fill="#5f8f60"/>
|
||||
<rect x="33" y="34" width="15" height="14" rx="6" fill="#8fd694"/>
|
||||
<rect x="52" y="34" width="15" height="14" rx="6" fill="#8fd694"/>
|
||||
<rect x="36" y="50" width="28" height="26" rx="8" fill="#3d5c42"/>
|
||||
<rect x="22" y="40" width="8" height="24" rx="4" fill="#d3f0c9"/>
|
||||
<rect x="70" y="40" width="8" height="24" rx="4" fill="#d3f0c9"/>
|
||||
<rect x="21" y="66" width="7" height="20" rx="3.5" fill="#3d5c42"/>
|
||||
<rect x="72" y="66" width="7" height="20" rx="3.5" fill="#3d5c42"/>
|
||||
<rect x="34" y="80" width="13" height="42" rx="6" fill="#233026"/>
|
||||
<rect x="53" y="80" width="13" height="42" rx="6" fill="#233026"/>
|
||||
<rect x="36" y="126" width="10" height="30" rx="5" fill="#233026"/>
|
||||
<rect x="54" y="126" width="10" height="30" rx="5" fill="#233026"/>
|
||||
</svg>
|
||||
<figcaption>Framsida</figcaption>
|
||||
</figure>
|
||||
<figure>
|
||||
<svg width="150" height="290" viewBox="0 0 100 195" role="img" aria-label="Kroppskarta baksida">
|
||||
<circle cx="50" cy="12" r="9" fill="#233026"/>
|
||||
<rect x="40" y="22" width="20" height="10" rx="4" fill="#5f8f60"/>
|
||||
<rect x="34" y="33" width="32" height="22" rx="8" fill="#3d5c42"/>
|
||||
<rect x="38" y="57" width="24" height="18" rx="7" fill="#233026"/>
|
||||
<rect x="22" y="40" width="8" height="24" rx="4" fill="#3d5c42"/>
|
||||
<rect x="70" y="40" width="8" height="24" rx="4" fill="#3d5c42"/>
|
||||
<rect x="21" y="66" width="7" height="20" rx="3.5" fill="#233026"/>
|
||||
<rect x="72" y="66" width="7" height="20" rx="3.5" fill="#233026"/>
|
||||
<rect x="35" y="77" width="30" height="14" rx="6" fill="#233026"/>
|
||||
<rect x="34" y="93" width="13" height="32" rx="6" fill="#233026"/>
|
||||
<rect x="53" y="93" width="13" height="32" rx="6" fill="#233026"/>
|
||||
<rect x="36" y="128" width="10" height="28" rx="5" fill="#3d5c42"/>
|
||||
<rect x="54" y="128" width="10" height="28" rx="5" fill="#3d5c42"/>
|
||||
</svg>
|
||||
<figcaption>Baksida</figcaption>
|
||||
</figure>
|
||||
</div>
|
||||
<div class="notes" style="justify-content:center">
|
||||
<div class="note"><div class="dot">→</div><p><b>Färgskalan är volym i perioden</b>
|
||||
— här syns direkt att bröst/underarmar fått mest och att ben & mage släpar.
|
||||
Tryck på en muskel → övningarna och seten bakom siffran.</p></div>
|
||||
<div class="heatlegend">Mindre
|
||||
<i style="background:var(--heat0)"></i><i style="background:var(--heat1)"></i>
|
||||
<i style="background:var(--heat2)"></i><i style="background:var(--heat3)"></i>
|
||||
<i style="background:var(--heat4)"></i> Mer volym</div>
|
||||
<div class="note" style="margin-top:10px"><div class="dot">✎</div><p>Figuren är
|
||||
medvetet stiliserad (byggd av former, inte anatomisk) — den blir tydlig i litet
|
||||
format, temafärgas och kräver inga bildlicenser. Vill du ha en mer anatomisk
|
||||
siluett går det, men liten och skarp slår detaljerad och grötig på en telefon.</p></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ============ 6. NAVIGERING + API ============ -->
|
||||
<section>
|
||||
<div class="secnum">6 / 6</div>
|
||||
<h2>Navigeringen — chrome runt tre lägen</h2>
|
||||
<p class="secintro">Med passläge + minispelare behövs färre flikar än webben har:
|
||||
<b>Hem · Historik · Statistik · Profil</b> med startknappen i mitten (förslag B
|
||||
från förra rundan). PB och kroppskartan bor i Statistik, grunddata och
|
||||
skivinställningar under Profil. Fritt pass, favoriter, widget och appgenvägar
|
||||
gör att flikarna nästan aldrig behövs på väg <i>in</i> i ett pass — de är för
|
||||
uppföljningen.</p>
|
||||
|
||||
<h2 style="margin-top:36px">Små API-tillägg som lyfter appen</h2>
|
||||
<p class="secintro">Inget av detta blockerar bygget — appen kan falla tillbaka på
|
||||
vettiga defaultvärden tills de finns.</p>
|
||||
<div class="tablewrap"><table>
|
||||
<tr><th>Tillägg</th><th>Vad det ger</th><th>Storlek</th></tr>
|
||||
<tr><td>MuscleGroup.iconKey</td>
|
||||
<td>Servern pekar ut vilken ikon en muskelgrupp har istället för att appen
|
||||
gissar på namnsträngar ("Bröst" vs "Chest"). En kolumn + fält i GraphQL.</td>
|
||||
<td><span class="pill-sm s">LITEN</span></td></tr>
|
||||
<tr><td>ExerciseType.defaultRestSeconds</td>
|
||||
<td>Auto-vilotimern vet hur länge just den övningen vilar (90 s bänk, 180 s
|
||||
marklyft). Fallback i appen: 90 s. Kolumn + fält + med i mallexport.</td>
|
||||
<td><span class="pill-sm s">LITEN</span></td></tr>
|
||||
<tr><td>Lift/Set: clientId (UUID)</td>
|
||||
<td>Offline-läge på gymmet: appen kan köa set lokalt och synka i efterhand utan
|
||||
dubbletter när samma mutation skickas om. Unik kolumn + upsert-logik.</td>
|
||||
<td><span class="pill-sm m">MEDEL · roadmap</span></td></tr>
|
||||
<tr><td>ExerciseType.imageUrl</td>
|
||||
<td>Frivillig påbyggnad om du senare vill ha riktiga foton/anatomibilder per
|
||||
övning — appen visar dem när de finns, ikoner annars.</td>
|
||||
<td><span class="pill-sm s">LITEN · valfri</span></td></tr>
|
||||
</table></div>
|
||||
|
||||
<div class="decide">
|
||||
<h2>Det jag behöver från dig</h2>
|
||||
<ol>
|
||||
<li><b>Passläget:</b> köper du "en övning i taget + svep + vilo-takeover", eller
|
||||
vill du hellre se hela passet som scrollbar lista med allt synligt?</li>
|
||||
<li><b>Muskelfiltret:</b> chips med ikoner (sektion 3) eller kroppskartan
|
||||
(sektion 5) som väljare — eller båda (chips snabbt, karta bakom en knapp)?</li>
|
||||
<li><b>Android-ytorna:</b> vilka vill du ha i första versionen — notistimer,
|
||||
widget, appgenvägar, skivkalkylator? (Notistimern föreslår jag ingår direkt,
|
||||
resten kan komma stegvis.)</li>
|
||||
<li><b>API-tilläggen:</b> ok att jag gör iconKey + defaultRestSeconds i
|
||||
gym-API:t (byggs och rullas ut via befintliga CI:t)?</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
100
doc/openscale-integration.md
Normal file
100
doc/openscale-integration.md
Normal file
@@ -0,0 +1,100 @@
|
||||
# openScale BT-vågar — integrationsplan
|
||||
|
||||
Bygga in [openScale](https://github.com/oliexdev/openScale):s Bluetooth-
|
||||
drivrutiner i FitnessDroid så att vägningar (vikt + %muskler/fett/vatten)
|
||||
kan hämtas direkt från en BT-våg in i appens kroppsmätningar.
|
||||
|
||||
## Beslut (2026-07-26)
|
||||
|
||||
- **Licens: FitnessDroid relicensas till GPL-3.0.** openScale är GPL-3.0;
|
||||
att bädda in deras kod kräver att hela appen blir GPL-3.0. LICENSE byts,
|
||||
och källfilerna som kommer från openScale behåller sina copyright-headers.
|
||||
- **Alla drivrutiner portas** (hela `core/bluetooth`-paketet, ~55 handlers +
|
||||
adaptrar), inte bara en modell. `ScaleFactory` auto-detekterar vågen vid
|
||||
skanning precis som i openScale.
|
||||
- **Attribution:** en **About**-vy under Profil som anger att appen använder
|
||||
openScales drivrutiner, med länk till deras GitHub och GPL-3.0-licensen,
|
||||
plus en lista på öppen källkod som används (openScale, Blessed-Kotlin).
|
||||
|
||||
## Teknik (bekräftat från repot)
|
||||
|
||||
- openScale använder **Blessed-Kotlin** (coroutine-BLE-wrapper) — läggs till
|
||||
som Gradle-beroende; vi portar drivrutinerna som ligger ovanpå, inte
|
||||
BLE-plumbingen.
|
||||
- `ScaleDeviceHandler.supportFor(device)` → `DeviceSupport` (displayName,
|
||||
linkMode, tuningProfile). `ScaleFactory` returnerar första matchande handler.
|
||||
- Tre kopplingslägen: `CONNECT_GATT`, `BROADCAST_ONLY`, `CLASSIC_SPP` med var
|
||||
sin adapter (Gatt/Broadcast/Spp).
|
||||
- `ScaleMeasurement` bär weight, fat, water, muscle, visceralFat, bone, lbm,
|
||||
bmr, protein, impedance m.m. → vi mappar weight + muscle/fat/water till vår
|
||||
`BodyMeasurement` (resten kan tas in senare).
|
||||
- minSdk 31 matchar vår app (BLE-permissions `BLUETOOTH_SCAN`/`_CONNECT`).
|
||||
|
||||
## Fallgrop: impedansvågar behöver användarprofil
|
||||
|
||||
Vissa vågar skickar bara **rå impedans**; openScale räknar då själv ut
|
||||
fett/muskler/vatten med en formel som kräver **längd, ålder, kön** (ScaleUser).
|
||||
Vi måste därför:
|
||||
- Lägga till längd/födelseår/kön i profilen (se API-ändring nedan).
|
||||
- Porta openScales BIA-beräkning för de vågar som kräver den.
|
||||
Vågar som räknar ombord och skickar färdiga procent behöver inte detta.
|
||||
|
||||
## API-ändring: spara användarens längd (+ ålder/kön)
|
||||
|
||||
Längden ska sparas **på servern** (gym-API:t) så att den är gemensam för app
|
||||
och webb, överlever ominstallation, och kan användas för mer korrekta
|
||||
beräkningar:
|
||||
|
||||
- **BIA/kroppssammansättning:** openScales formler för impedansvågar kräver
|
||||
längd (+ ålder + kön) för att ge korrekta %fett/muskler/vatten.
|
||||
- **Energiförbrukning:** med längd (+ ålder + kön) kan kaloriberäkningen gå
|
||||
från ren MET × vikt × tid till en BMR-baserad uppskattning
|
||||
(Mifflin–St Jeor: `10·vikt + 6.25·längd − 5·ålder + könskonstant`), vilket
|
||||
ger rimligare siffror per pass.
|
||||
|
||||
**Backend (brasse-pc.eu-v2, gym-api):**
|
||||
- Utöka `User` med `HeightCm` (double?), och för full BIA även `BirthDate`
|
||||
(eller `BirthYear`) och `Sex` (enum/sträng). Nullable + bakåtkompatibla.
|
||||
- Migration (körs automatiskt vid uppstart, samma mönster som
|
||||
`BodyMeasurement`).
|
||||
- Exponera i `myProfile`-query och en `updateProfile`-mutation (eller utöka
|
||||
`updateBodyWeight` → `updateProfile` med längd/ålder/kön).
|
||||
- Deploya om gym-api-prod + -test (serialiserat bygge, se
|
||||
[[pi5-ci-concurrency]]).
|
||||
|
||||
**App:** profil-fält för längd (och ålder/kön) som läser/skriver mot API:t;
|
||||
cachas lokalt (som kroppsvikten) för offline och för BIA-beräkningen.
|
||||
|
||||
**Webb (brasse-pc.eu-v2):** samma fält på profil-fliken via `updateProfile`.
|
||||
|
||||
## Milstolpar (byggbara steg)
|
||||
|
||||
1. **Grund + licens + About** — LICENSE → GPL-3.0; `blessed-kotlin`-beroende;
|
||||
BLE-permissions i manifestet; About-vy under Profil (attribution + länkar +
|
||||
openetlicens-text); körruntime-permission-flöde. *Ingen vågkod än — bara
|
||||
att allt kompilerar och About visas.*
|
||||
2. **Porta bluetooth-paketet** — vendora `com.health.openscale.core.bluetooth`
|
||||
(ScaleCommunicator, ScaleFactory, scales/, adaptrar, data/, libs/) med
|
||||
copyright-headers kvar. Slimmad `ScaleMeasurement`/`ScaleUser`. Får det att
|
||||
kompilera mot Blessed.
|
||||
3. **Skanna + koppla** — "Anslut våg" i inställningar: skanna BLE, kör
|
||||
`ScaleFactory` mot varje enhet, visa matchade vågar, spara vald MAC.
|
||||
4. **Väg dig-flöde** — knapp som skannar/kopplar vald våg, tar emot en
|
||||
`ScaleMeasurement`, förhandsvisar och sparar via `addBodyMeasurement`
|
||||
(offline-kön tar resten). Live-status ("står på vågen…").
|
||||
5. **API: längd (+ ålder/kön)** — utöka gym-API:ts `User` med `HeightCm`
|
||||
(+ `BirthDate`/`Sex`), migration, `myProfile` + `updateProfile`, deploy.
|
||||
App + webb får profilfält. Se "API-ändring" ovan.
|
||||
6. **Impedansvågar + bättre kalorier** — BIA-beräkning för vågar som skickar
|
||||
rå impedans (använder längd/ålder/kön), och BMR-baserad energiförbrukning.
|
||||
Verifieras mot din våg.
|
||||
7. **Polering + docs** — felhantering, timeouts, ominställning av våg;
|
||||
uppdatera README/wiki + infra-Doc (GPL-relicens noterad).
|
||||
|
||||
## Öppna frågor / risker
|
||||
|
||||
- **Vilken våg du har** avgör om milstolpe 5 (impedans-BIA) behövs eller om
|
||||
din våg skickar färdiga procent. Identifieras i milstolpe 3 via skanning.
|
||||
- Blessed-Kotlins exakta koordinater/version verifieras vid milstolpe 1.
|
||||
- APK växer (drivrutiner + BLE-lib) — troligen någon MB, oproblematiskt.
|
||||
- GPL-relicens är permanent för koden; medvetet val.
|
||||
41
doc/plan.md
41
doc/plan.md
@@ -28,13 +28,44 @@ mallar, grunddata och profil.
|
||||
|
||||
## Milstolpar
|
||||
|
||||
1. **Grund + login** *(klar i och med första commit)*
|
||||
1. **Grund + login** *(klar 2026-07-23)*
|
||||
Projektskelett som kompilerar, inloggning mot API:t med sessions-
|
||||
återställning vid appstart, hemskärm som visar profilen.
|
||||
2. **Aktivt pass** — starta/återuppta pass, lägga till övningar, logga set
|
||||
(reps/vikt/distans/tid/RPE), vilotimer, passtimer, avsluta pass.
|
||||
Fälten styrs av övningens tracks-flaggor precis som i webben.
|
||||
3. **Passhistorik** — lista, visa, redigera och ta bort tidigare pass.
|
||||
2. **Aktivt pass** *(klar 2026-07-24)* — passläge med en övning i taget
|
||||
(svep), ±steppers och ett-trycks setloggning med förifyllda värden,
|
||||
vilotimer (helskärm/banner + larm/vibration/visuell/inget — inställbart),
|
||||
offline-först via Room + synk-kö med statusindikatorer, övningsväljare
|
||||
med muskelfilter, bottenrad-navigation med start-FAB och minispelare.
|
||||
API-tillägg: `MuscleGroup.iconKey`, `ExerciseType.defaultRestSeconds`.
|
||||
*Polering 2026-07-24/25:* rätta/ta bort loggade set, passdetaljvy i
|
||||
historiken, övningshistorik i passläget (tidigare pass + tyngsta 3 mån),
|
||||
avsluta-överblick med redigerbar start/varaktighet (date/time-pickers,
|
||||
hopfällt bakom "Ändra tid"), kcal-summering med "Hur räknas detta?"
|
||||
(MET × kroppsvikt × timmar, även i historiken) och 🏆-markering av
|
||||
nya personbästa per rep-antal (PB-cache i Room).
|
||||
3. **Statistik + robust inloggning** *(klar 2026-07-25, version 0.3.0)* —
|
||||
statistikfliken med periodväljare (vecka/månad/halvår/år/totalt),
|
||||
KPI-kort med delta mot förra perioden, scrollbar volymtrend,
|
||||
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 (aktiveras vid inloggning; toggle i
|
||||
inställningarna). Utökade inställningar: viktsteg (1,25/2,5/5 kg),
|
||||
API-url, auto-återinloggning, egna stångvikter.
|
||||
*Dessutom:* **in-app-uppdatering** (CI publicerar version.json;
|
||||
hemskärmskort laddar ner, sha256-verifierar och installerar),
|
||||
**skivkalkylator** (tryck på vikten i aktiva setet; stång 20/0/egna,
|
||||
skivor i standardfärger), **passlängd** i historiken, och
|
||||
**kroppsviktspårning**: API-modellen BodyMeasurement (uuid, datum,
|
||||
vikt + muskel/fett/vatten-% för framtiden), uppdatera/rätta/ta bort
|
||||
mätningar från profilen med datumväljare, viktgraf i statistiken med
|
||||
glidande medelvärde och linjär trendlinje. Profilens bodyWeightKg
|
||||
speglar alltid senaste mätningen.
|
||||
4. **BT-våg via openScale-drivrutiner** *(planerad)* — hämta vägningar
|
||||
(vikt + %muskler/fett/vatten) direkt från en Bluetooth-våg genom att
|
||||
bädda in openScales drivrutiner. Kräver relicens till GPL-3.0. Full plan:
|
||||
[`doc/openscale-integration.md`](openscale-integration.md).
|
||||
5. **Passhistorik** — lista, visa, redigera och ta bort tidigare pass.
|
||||
4. **Logga lyft (snabbloggning) + PB** — enkel lyftlogg och PB-matrisen.
|
||||
5. **Statistik** — perioder (vecka/månad/halvår/år), KPI-kort, trenddiagram,
|
||||
muskelfördelning, aktivitets-heatmap.
|
||||
|
||||
@@ -9,6 +9,8 @@ navigationCompose = "2.8.5"
|
||||
okhttp = "4.12.0"
|
||||
kotlinxSerialization = "1.7.3"
|
||||
datastore = "1.1.1"
|
||||
room = "2.6.1"
|
||||
ksp = "2.0.21-1.0.28"
|
||||
|
||||
[libraries]
|
||||
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
|
||||
@@ -26,9 +28,13 @@ androidx-navigation-compose = { group = "androidx.navigation", name = "navigatio
|
||||
androidx-datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastore" }
|
||||
okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" }
|
||||
kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinxSerialization" }
|
||||
androidx-room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" }
|
||||
androidx-room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "room" }
|
||||
androidx-room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" }
|
||||
|
||||
[plugins]
|
||||
android-application = { id = "com.android.application", version.ref = "agp" }
|
||||
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
|
||||
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
|
||||
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
|
||||
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
|
||||
|
||||
Reference in New Issue
Block a user