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()
|
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 ---------- */
|
/* ---------- Referensdata / cache ---------- */
|
||||||
|
|
||||||
/** Hämta om all referensdata. Tyst vid nätfel — cachen gäller tills vidare. */
|
/** 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 {
|
runCatching {
|
||||||
val prof = client.execute("query{myProfile{bodyWeightKg}}", token = token)
|
val prof = client.execute("query{myProfile{bodyWeightKg}}", token = token)
|
||||||
store.saveBodyWeight(
|
store.saveBodyWeight(
|
||||||
|
|||||||
@@ -17,8 +17,9 @@ import androidx.sqlite.db.SupportSQLiteDatabase
|
|||||||
CachedMuscleGroup::class,
|
CachedMuscleGroup::class,
|
||||||
CachedMuscle::class,
|
CachedMuscle::class,
|
||||||
CachedStartCard::class,
|
CachedStartCard::class,
|
||||||
|
CachedPb::class,
|
||||||
],
|
],
|
||||||
version = 3,
|
version = 4,
|
||||||
exportSchema = false,
|
exportSchema = false,
|
||||||
)
|
)
|
||||||
abstract class AppDatabase : RoomDatabase() {
|
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 =
|
fun build(context: Context): AppDatabase =
|
||||||
Room.databaseBuilder(context, AppDatabase::class.java, "fitnessdroid.db")
|
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()
|
.fallbackToDestructiveMigration()
|
||||||
.build()
|
.build()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -135,4 +135,13 @@ interface CacheDao {
|
|||||||
|
|
||||||
@Query("UPDATE cached_exercise_type SET isFavorite = :favorite WHERE id = :id")
|
@Query("UPDATE cached_exercise_type SET isFavorite = :favorite WHERE id = :id")
|
||||||
suspend fun setExerciseFavorite(id: Int, favorite: Boolean)
|
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,
|
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. */
|
/** Favoritpass/mallar som visas på hemskärmen, cachade för offline-start. */
|
||||||
@Entity(tableName = "cached_start_card")
|
@Entity(tableName = "cached_start_card")
|
||||||
data class CachedStartCard(
|
data class CachedStartCard(
|
||||||
|
|||||||
@@ -192,9 +192,11 @@ fun SessionScreen(
|
|||||||
|
|
||||||
if (confirmComplete) {
|
if (confirmComplete) {
|
||||||
val bodyWeight by viewModel.bodyWeightKg.collectAsStateWithLifecycle()
|
val bodyWeight by viewModel.bodyWeightKg.collectAsStateWithLifecycle()
|
||||||
|
val personalBests by viewModel.personalBests.collectAsStateWithLifecycle()
|
||||||
CompleteSessionDialog(
|
CompleteSessionDialog(
|
||||||
uiState = uiState,
|
uiState = uiState,
|
||||||
bodyWeightKg = bodyWeight,
|
bodyWeightKg = bodyWeight,
|
||||||
|
personalBests = personalBests,
|
||||||
onConfirm = { durationSeconds, editedStartEpochMs ->
|
onConfirm = { durationSeconds, editedStartEpochMs ->
|
||||||
confirmComplete = false
|
confirmComplete = false
|
||||||
viewModel.completeSession(durationSeconds, editedStartEpochMs, onSessionEnded)
|
viewModel.completeSession(durationSeconds, editedStartEpochMs, onSessionEnded)
|
||||||
@@ -457,6 +459,7 @@ private fun Stepper(label: String, value: String, onMinus: () -> Unit, onPlus: (
|
|||||||
private fun CompleteSessionDialog(
|
private fun CompleteSessionDialog(
|
||||||
uiState: SessionUiState,
|
uiState: SessionUiState,
|
||||||
bodyWeightKg: Double?,
|
bodyWeightKg: Double?,
|
||||||
|
personalBests: Map<Pair<Int, Int>, Double>,
|
||||||
onConfirm: (durationSeconds: Int, editedStartEpochMs: Long?) -> Unit,
|
onConfirm: (durationSeconds: Int, editedStartEpochMs: Long?) -> Unit,
|
||||||
onDismiss: () -> 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 totalSets = uiState.pages.sumOf { it.doneSets.size }
|
||||||
val totalReps = uiState.pages.sumOf { p -> p.doneSets.sumOf { it.reps ?: 0 } }
|
val totalReps = uiState.pages.sumOf { p -> p.doneSets.sumOf { it.reps ?: 0 } }
|
||||||
val volumeKg = uiState.pages.sumOf { p ->
|
val volumeKg = uiState.pages.sumOf { p ->
|
||||||
@@ -618,15 +644,58 @@ private fun CompleteSessionDialog(
|
|||||||
onDismissRequest = onDismiss,
|
onDismissRequest = onDismiss,
|
||||||
title = { Text("Avsluta ${uiState.sessionName}?") },
|
title = { Text("Avsluta ${uiState.sessionName}?") },
|
||||||
text = {
|
text = {
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
Column(
|
||||||
|
modifier = Modifier.verticalScroll(rememberScrollState()),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
) {
|
||||||
Text(
|
Text(
|
||||||
buildString {
|
buildString {
|
||||||
append("${uiState.pages.count { it.doneSets.isNotEmpty() }} övningar · $totalSets set · $totalReps reps")
|
append("${uiState.pages.count { it.doneSets.isNotEmpty() }} övningar · $totalSets set · $totalReps reps")
|
||||||
if (volumeKg > 0) append("\n${volumeKg.toInt()} kg total volym")
|
if (volumeKg > 0) append("\n${volumeKg.toInt()} kg total volym")
|
||||||
|
if (pbSetIds.isNotEmpty()) append("\n🏆 ${pbSetIds.size} nya personbästa!")
|
||||||
},
|
},
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
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 zone = java.time.ZoneId.systemDefault()
|
||||||
val start = java.time.Instant.ofEpochMilli(startEpochMs).atZone(zone)
|
val start = java.time.Instant.ofEpochMilli(startEpochMs).atZone(zone)
|
||||||
val end = java.time.Instant.ofEpochMilli(startEpochMs + durationSeconds * 1000L)
|
val end = java.time.Instant.ofEpochMilli(startEpochMs + durationSeconds * 1000L)
|
||||||
|
|||||||
@@ -208,8 +208,14 @@ class SessionViewModel(
|
|||||||
/** Kroppsvikt för kaloriuppskattningen (cachad från profilen). */
|
/** Kroppsvikt för kaloriuppskattningen (cachad från profilen). */
|
||||||
val bodyWeightKg = MutableStateFlow<Double?>(null)
|
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 {
|
init {
|
||||||
viewModelScope.launch { bodyWeightKg.value = repo.bodyWeightKg() }
|
viewModelScope.launch {
|
||||||
|
bodyWeightKg.value = repo.bodyWeightKg()
|
||||||
|
personalBests.value = repo.personalBests()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun completeSession(
|
fun completeSession(
|
||||||
|
|||||||
Reference in New Issue
Block a user