Avsluta-överblicken: övningslista med loggade set och PB-markering
All checks were successful
release / build-release (push) Successful in 4m52s
All checks were successful
release / build-release (push) Successful in 4m52s
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Kf4MAbZ3c3B4Xy5XfuGsCP
This commit is contained in:
@@ -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<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. */
|
||||
@@ -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(
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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<CachedPb>
|
||||
|
||||
@Query("DELETE FROM cached_pb")
|
||||
suspend fun clearPersonalBests()
|
||||
|
||||
@androidx.room.Upsert
|
||||
suspend fun upsertPersonalBests(rows: List<CachedPb>)
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
/** "<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(
|
||||
|
||||
@@ -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<Pair<Int, Int>, 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<Int, Double>()
|
||||
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)
|
||||
|
||||
@@ -208,8 +208,14 @@ class SessionViewModel(
|
||||
/** 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() }
|
||||
viewModelScope.launch {
|
||||
bodyWeightKg.value = repo.bodyWeightKg()
|
||||
personalBests.value = repo.personalBests()
|
||||
}
|
||||
}
|
||||
|
||||
fun completeSession(
|
||||
|
||||
Reference in New Issue
Block a user