From 493baee88ddf759eb350fe7deab8812d91c94c41 Mon Sep 17 00:00:00 2001 From: claude Date: Sat, 25 Jul 2026 11:57:25 +0200 Subject: [PATCH] =?UTF-8?q?Avsluta-=C3=B6verblicken:=20=C3=B6vningslista?= =?UTF-8?q?=20med=20loggade=20set=20och=20PB-markering?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Varje övning listas med sina set (vikt×reps m.m.) och set som slår cachade personbästat för samma rep-antal märks med trofé — även mot tidigare set i samma pass. Personbästa cachas från API:t vid refresh (cached_pb, Room v4). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Kf4MAbZ3c3B4Xy5XfuGsCP --- .../fitnessdroid/data/GymRepository.kt | 28 ++++++++ .../fitnessdroid/data/local/AppDatabase.kt | 16 ++++- .../brassepc/fitnessdroid/data/local/Daos.kt | 9 +++ .../fitnessdroid/data/local/Entities.kt | 10 +++ .../fitnessdroid/ui/session/SessionScreen.kt | 71 ++++++++++++++++++- .../ui/session/SessionViewModel.kt | 8 ++- 6 files changed, 138 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/eu/brassepc/fitnessdroid/data/GymRepository.kt b/app/src/main/java/eu/brassepc/fitnessdroid/data/GymRepository.kt index 5a7302a..bd4c3a8 100644 --- a/app/src/main/java/eu/brassepc/fitnessdroid/data/GymRepository.kt +++ b/app/src/main/java/eu/brassepc/fitnessdroid/data/GymRepository.kt @@ -161,6 +161,10 @@ class GymRepository( suspend fun bodyWeightKg(): Double? = store.bodyWeightKg() + /** Cachade personbästa som (exerciseTypeId, reps) → bästa vikt. */ + suspend fun personalBests(): Map, 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. */ @@ -210,6 +214,30 @@ class GymRepository( ) } + 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}}", token = token) store.saveBodyWeight( diff --git a/app/src/main/java/eu/brassepc/fitnessdroid/data/local/AppDatabase.kt b/app/src/main/java/eu/brassepc/fitnessdroid/data/local/AppDatabase.kt index facaf14..160947f 100644 --- a/app/src/main/java/eu/brassepc/fitnessdroid/data/local/AppDatabase.kt +++ b/app/src/main/java/eu/brassepc/fitnessdroid/data/local/AppDatabase.kt @@ -17,8 +17,9 @@ import androidx.sqlite.db.SupportSQLiteDatabase CachedMuscleGroup::class, CachedMuscle::class, CachedStartCard::class, + CachedPb::class, ], - version = 3, + version = 4, exportSchema = false, ) abstract class AppDatabase : RoomDatabase() { @@ -39,9 +40,20 @@ abstract class AppDatabase : RoomDatabase() { } } + 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) + .addMigrations(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4) .fallbackToDestructiveMigration() .build() } diff --git a/app/src/main/java/eu/brassepc/fitnessdroid/data/local/Daos.kt b/app/src/main/java/eu/brassepc/fitnessdroid/data/local/Daos.kt index 633e813..76c31ac 100644 --- a/app/src/main/java/eu/brassepc/fitnessdroid/data/local/Daos.kt +++ b/app/src/main/java/eu/brassepc/fitnessdroid/data/local/Daos.kt @@ -135,4 +135,13 @@ interface CacheDao { @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 + + @Query("DELETE FROM cached_pb") + suspend fun clearPersonalBests() + + @androidx.room.Upsert + suspend fun upsertPersonalBests(rows: List) } diff --git a/app/src/main/java/eu/brassepc/fitnessdroid/data/local/Entities.kt b/app/src/main/java/eu/brassepc/fitnessdroid/data/local/Entities.kt index 47faa83..bd82402 100644 --- a/app/src/main/java/eu/brassepc/fitnessdroid/data/local/Entities.kt +++ b/app/src/main/java/eu/brassepc/fitnessdroid/data/local/Entities.kt @@ -110,6 +110,16 @@ data class CachedMuscle( val muscleGroupId: Int? = null, ) +/** Personbästa per övning och rep-antal — för PB-markering vid avslut. */ +@Entity(tableName = "cached_pb") +data class CachedPb( + /** "-" */ + @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( diff --git a/app/src/main/java/eu/brassepc/fitnessdroid/ui/session/SessionScreen.kt b/app/src/main/java/eu/brassepc/fitnessdroid/ui/session/SessionScreen.kt index d9ddb2e..2ff6a57 100644 --- a/app/src/main/java/eu/brassepc/fitnessdroid/ui/session/SessionScreen.kt +++ b/app/src/main/java/eu/brassepc/fitnessdroid/ui/session/SessionScreen.kt @@ -192,9 +192,11 @@ fun SessionScreen( if (confirmComplete) { val bodyWeight by viewModel.bodyWeightKg.collectAsStateWithLifecycle() + val personalBests by viewModel.personalBests.collectAsStateWithLifecycle() CompleteSessionDialog( uiState = uiState, bodyWeightKg = bodyWeight, + personalBests = personalBests, onConfirm = { durationSeconds, editedStartEpochMs -> confirmComplete = false viewModel.completeSession(durationSeconds, editedStartEpochMs, onSessionEnded) @@ -457,6 +459,7 @@ private fun Stepper(label: String, value: String, onMinus: () -> Unit, onPlus: ( private fun CompleteSessionDialog( uiState: SessionUiState, bodyWeightKg: Double?, + personalBests: Map, Double>, onConfirm: (durationSeconds: Int, editedStartEpochMs: Long?) -> Unit, onDismiss: () -> Unit, ) { @@ -587,6 +590,29 @@ private fun CompleteSessionDialog( ) } + // Ett set är PB om vikten slår cachade personbästat för samma rep-antal — + // och tidigare set i samma pass räknas också som ribba. + val pbSetIds = remember(uiState.pages, personalBests) { + buildSet { + uiState.pages.forEach { page -> + val typeId = page.type?.id ?: return@forEach + val sessionBest = mutableMapOf() + page.doneSets.sortedBy { it.order }.forEach { set -> + val w = set.weight + val r = set.reps + if (w != null && r != null && !set.isWarmup) { + val threshold = maxOf( + personalBests[typeId to r] ?: 0.0, + sessionBest[r] ?: 0.0, + ) + if (w > threshold) add(set.id) + sessionBest[r] = maxOf(sessionBest[r] ?: 0.0, w) + } + } + } + } + } + val totalSets = uiState.pages.sumOf { it.doneSets.size } val totalReps = uiState.pages.sumOf { p -> p.doneSets.sumOf { it.reps ?: 0 } } val volumeKg = uiState.pages.sumOf { p -> @@ -618,15 +644,58 @@ private fun CompleteSessionDialog( onDismissRequest = onDismiss, title = { Text("Avsluta ${uiState.sessionName}?") }, text = { - Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + Column( + modifier = Modifier.verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { Text( buildString { append("${uiState.pages.count { it.doneSets.isNotEmpty() }} övningar · $totalSets set · $totalReps reps") if (volumeKg > 0) append("\n${volumeKg.toInt()} kg total volym") + if (pbSetIds.isNotEmpty()) append("\n🏆 ${pbSetIds.size} nya personbästa!") }, style = MaterialTheme.typography.bodyMedium, ) + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + uiState.pages.filter { it.doneSets.isNotEmpty() }.forEach { page -> + Column { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + page.name, + style = MaterialTheme.typography.titleSmall, + modifier = Modifier.weight(1f), + ) + if (page.doneSets.any { it.id in pbSetIds }) { + Text( + "🏆 PB", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.tertiary, + ) + } + } + Text( + page.doneSets.sortedBy { it.order }.joinToString(" · ") { set -> + buildString { + when { + set.weight != null && set.reps != null -> + append("${set.weight.compact()}×${set.reps}") + set.reps != null -> append("${set.reps} reps") + set.durationSeconds != null -> append("${set.durationSeconds}s") + set.distanceMeters != null -> + append("${set.distanceMeters.compact()} m") + else -> append("set") + } + if (set.id in pbSetIds) append(" 🏆") + } + }, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + val zone = java.time.ZoneId.systemDefault() val start = java.time.Instant.ofEpochMilli(startEpochMs).atZone(zone) val end = java.time.Instant.ofEpochMilli(startEpochMs + durationSeconds * 1000L) diff --git a/app/src/main/java/eu/brassepc/fitnessdroid/ui/session/SessionViewModel.kt b/app/src/main/java/eu/brassepc/fitnessdroid/ui/session/SessionViewModel.kt index 37610df..1ce57f4 100644 --- a/app/src/main/java/eu/brassepc/fitnessdroid/ui/session/SessionViewModel.kt +++ b/app/src/main/java/eu/brassepc/fitnessdroid/ui/session/SessionViewModel.kt @@ -208,8 +208,14 @@ class SessionViewModel( /** Kroppsvikt för kaloriuppskattningen (cachad från profilen). */ val bodyWeightKg = MutableStateFlow(null) + /** Personbästa (typeId, reps) → vikt, för 🏆-markeringen vid avslut. */ + val personalBests = MutableStateFlow, Double>>(emptyMap()) + init { - viewModelScope.launch { bodyWeightKg.value = repo.bodyWeightKg() } + viewModelScope.launch { + bodyWeightKg.value = repo.bodyWeightKg() + personalBests.value = repo.personalBests() + } } fun completeSession(