Avsluta-överblick med redigerbar passtid + kcal-summering med uträkning
All checks were successful
release / build-release (push) Successful in 5m37s
All checks were successful
release / build-release (push) Successful in 5m37s
- Avsluta-dialogen visar övningar/set/reps/volym, passtiden går att justera (±1/±5 min, skickas som durationSecondsOverride — för alla gånger man glömt avsluta) och en kcal-uppskattning som räknas om live med tiden - 'Hur räknas detta?' visar MET-formeln (MET × kroppsvikt × timmar) med siffrorna per övning; kroppsvikten cachas från profilen - Passdetaljen i historiken: summeringskort (tid, set, volym, kcal) + samma förklaringsdialog med serverns faktiska fördelning, och kcal per övning på varje övningskort - Room v2-migration för durationSecondsOverride Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Kf4MAbZ3c3B4Xy5XfuGsCP
This commit is contained in:
@@ -25,7 +25,7 @@ class AppContainer(context: Context) {
|
|||||||
val gymApi = GymApi(graphQlClient, authRepository)
|
val gymApi = GymApi(graphQlClient, authRepository)
|
||||||
val database = AppDatabase.build(context)
|
val database = AppDatabase.build(context)
|
||||||
val syncEngine = SyncEngine(database, graphQlClient, authRepository, appScope)
|
val syncEngine = SyncEngine(database, graphQlClient, authRepository, appScope)
|
||||||
val gymRepository = GymRepository(database, graphQlClient, authRepository, syncEngine, appScope)
|
val gymRepository = GymRepository(database, graphQlClient, authRepository, syncEngine, tokenStore, appScope)
|
||||||
val restTimer = RestTimerController(context, settingsStore, appScope)
|
val restTimer = RestTimerController(context, settingsStore, appScope)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ class GymRepository(
|
|||||||
private val client: GraphQlClient,
|
private val client: GraphQlClient,
|
||||||
private val auth: AuthRepository,
|
private val auth: AuthRepository,
|
||||||
private val sync: SyncEngine,
|
private val sync: SyncEngine,
|
||||||
|
private val store: TokenStore,
|
||||||
private val scope: CoroutineScope,
|
private val scope: CoroutineScope,
|
||||||
) {
|
) {
|
||||||
val activeSession = db.sessionDao().activeSession()
|
val activeSession = db.sessionDao().activeSession()
|
||||||
@@ -141,12 +142,16 @@ class GymRepository(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun completeSession(sessionId: Long) {
|
suspend fun completeSession(sessionId: Long, durationSecondsOverride: Int? = null) {
|
||||||
val session = db.sessionDao().session(sessionId) ?: return
|
val session = db.sessionDao().session(sessionId) ?: return
|
||||||
db.sessionDao().updateSession(session.copy(status = "completed"))
|
db.sessionDao().updateSession(
|
||||||
|
session.copy(status = "completed", durationSecondsOverride = durationSecondsOverride)
|
||||||
|
)
|
||||||
enqueue(OpKind.COMPLETE_SESSION, sessionId)
|
enqueue(OpKind.COMPLETE_SESSION, sessionId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
suspend fun bodyWeightKg(): Double? = store.bodyWeightKg()
|
||||||
|
|
||||||
/* ---------- 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. */
|
||||||
@@ -196,6 +201,13 @@ class GymRepository(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
runCatching {
|
||||||
|
val prof = client.execute("query{myProfile{bodyWeightKg}}", token = token)
|
||||||
|
store.saveBodyWeight(
|
||||||
|
prof["myProfile"]?.jsonObject?.get("bodyWeightKg")?.jsonPrimitive?.doubleOrNull
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
val favIds: Set<Int> = runCatching {
|
val favIds: Set<Int> = runCatching {
|
||||||
client.execute("query{favoriteExercises{id}}", token = token)["favoriteExercises"]!!
|
client.execute("query{favoriteExercises{id}}", token = token)["favoriteExercises"]!!
|
||||||
.jsonArray.map { it.jsonObject["id"]!!.jsonPrimitive.int }.toSet()
|
.jsonArray.map { it.jsonObject["id"]!!.jsonPrimitive.int }.toSet()
|
||||||
@@ -452,6 +464,34 @@ class GymRepository(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 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). */
|
/** Historik hämtas direkt (ingen offline-garanti i v1). */
|
||||||
suspend fun fetchHistory(limit: Int = 30): List<HistoryItem> {
|
suspend fun fetchHistory(limit: Int = 30): List<HistoryItem> {
|
||||||
val data = client.execute(
|
val data = client.execute(
|
||||||
@@ -527,5 +567,22 @@ data class ExerciseHistory(
|
|||||||
val topLifts: List<TopLift>,
|
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?,
|
||||||
|
)
|
||||||
|
|
||||||
private fun kotlinx.serialization.json.JsonPrimitive.contentOrNull(): String? =
|
private fun kotlinx.serialization.json.JsonPrimitive.contentOrNull(): String? =
|
||||||
if (this is kotlinx.serialization.json.JsonNull) null else content
|
if (this is kotlinx.serialization.json.JsonNull) null else content
|
||||||
|
|||||||
@@ -186,6 +186,7 @@ class SyncEngine(
|
|||||||
val input = buildJsonObject {
|
val input = buildJsonObject {
|
||||||
put("id", sessionServerId)
|
put("id", sessionServerId)
|
||||||
session.name?.let { put("name", it) }
|
session.name?.let { put("name", it) }
|
||||||
|
session.durationSecondsOverride?.let { put("durationSecondsOverride", it) }
|
||||||
}
|
}
|
||||||
client.execute(
|
client.execute(
|
||||||
"mutation(\$input: CompleteGymSessionInput!){completeGymSession(input:\$input){id status}}",
|
"mutation(\$input: CompleteGymSessionInput!){completeGymSession(input:\$input){id status}}",
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package eu.brassepc.fitnessdroid.data
|
package eu.brassepc.fitnessdroid.data
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import androidx.datastore.preferences.core.doublePreferencesKey
|
||||||
import androidx.datastore.preferences.core.edit
|
import androidx.datastore.preferences.core.edit
|
||||||
import androidx.datastore.preferences.core.longPreferencesKey
|
import androidx.datastore.preferences.core.longPreferencesKey
|
||||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||||
@@ -63,6 +64,16 @@ 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]
|
||||||
|
|
||||||
/** Rensar sessionen men behåller vald API-url. */
|
/** Rensar sessionen men behåller vald API-url. */
|
||||||
suspend fun clearSession() {
|
suspend fun clearSession() {
|
||||||
context.authDataStore.edit {
|
context.authDataStore.edit {
|
||||||
@@ -83,5 +94,6 @@ class TokenStore(private val context: Context) {
|
|||||||
private val KEY_REFRESH_TOKEN = stringPreferencesKey("refresh_token")
|
private val KEY_REFRESH_TOKEN = stringPreferencesKey("refresh_token")
|
||||||
private val KEY_EXPIRATION = longPreferencesKey("expiration_epoch_ms")
|
private val KEY_EXPIRATION = longPreferencesKey("expiration_epoch_ms")
|
||||||
private val KEY_API_URL = stringPreferencesKey("api_url")
|
private val KEY_API_URL = stringPreferencesKey("api_url")
|
||||||
|
private val KEY_BODY_WEIGHT = doublePreferencesKey("body_weight_kg")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import android.content.Context
|
|||||||
import androidx.room.Database
|
import androidx.room.Database
|
||||||
import androidx.room.Room
|
import androidx.room.Room
|
||||||
import androidx.room.RoomDatabase
|
import androidx.room.RoomDatabase
|
||||||
|
import androidx.room.migration.Migration
|
||||||
|
import androidx.sqlite.db.SupportSQLiteDatabase
|
||||||
|
|
||||||
@Database(
|
@Database(
|
||||||
entities = [
|
entities = [
|
||||||
@@ -16,7 +18,7 @@ import androidx.room.RoomDatabase
|
|||||||
CachedMuscle::class,
|
CachedMuscle::class,
|
||||||
CachedStartCard::class,
|
CachedStartCard::class,
|
||||||
],
|
],
|
||||||
version = 1,
|
version = 2,
|
||||||
exportSchema = false,
|
exportSchema = false,
|
||||||
)
|
)
|
||||||
abstract class AppDatabase : RoomDatabase() {
|
abstract class AppDatabase : RoomDatabase() {
|
||||||
@@ -25,8 +27,15 @@ abstract class AppDatabase : RoomDatabase() {
|
|||||||
abstract fun cacheDao(): CacheDao
|
abstract fun cacheDao(): CacheDao
|
||||||
|
|
||||||
companion object {
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
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)
|
||||||
.fallbackToDestructiveMigration()
|
.fallbackToDestructiveMigration()
|
||||||
.build()
|
.build()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ data class LocalSession(
|
|||||||
val startedAtEpochMs: Long,
|
val startedAtEpochMs: Long,
|
||||||
/** "active" eller "completed" (lokalt avslutad, ev. ej synkad än) */
|
/** "active" eller "completed" (lokalt avslutad, ev. ej synkad än) */
|
||||||
val status: String = "active",
|
val status: String = "active",
|
||||||
|
/** Manuellt rättad passtid — skickas som durationSecondsOverride vid avslut */
|
||||||
|
val durationSecondsOverride: Int? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
@Entity(
|
@Entity(
|
||||||
|
|||||||
@@ -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") }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -19,10 +19,14 @@ import androidx.compose.material3.IconButton
|
|||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.material3.TopAppBar
|
import androidx.compose.material3.TopAppBar
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
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.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
@@ -35,7 +39,10 @@ import androidx.lifecycle.viewmodel.initializer
|
|||||||
import androidx.lifecycle.viewmodel.viewModelFactory
|
import androidx.lifecycle.viewmodel.viewModelFactory
|
||||||
import eu.brassepc.fitnessdroid.data.GymRepository
|
import eu.brassepc.fitnessdroid.data.GymRepository
|
||||||
import eu.brassepc.fitnessdroid.data.SessionDetail
|
import eu.brassepc.fitnessdroid.data.SessionDetail
|
||||||
|
import eu.brassepc.fitnessdroid.data.SessionStats
|
||||||
import eu.brassepc.fitnessdroid.ui.appContainer
|
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 eu.brassepc.fitnessdroid.ui.common.describe
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
@@ -43,7 +50,11 @@ import kotlinx.coroutines.launch
|
|||||||
sealed interface DetailUiState {
|
sealed interface DetailUiState {
|
||||||
data object Loading : DetailUiState
|
data object Loading : DetailUiState
|
||||||
data class Error(val message: String) : DetailUiState
|
data class Error(val message: String) : DetailUiState
|
||||||
data class Ready(val detail: SessionDetail) : DetailUiState
|
data class Ready(
|
||||||
|
val detail: SessionDetail,
|
||||||
|
val stats: SessionStats?,
|
||||||
|
val bodyWeightKg: Double?,
|
||||||
|
) : DetailUiState
|
||||||
}
|
}
|
||||||
|
|
||||||
class HistoryDetailViewModel(private val repo: GymRepository) : ViewModel() {
|
class HistoryDetailViewModel(private val repo: GymRepository) : ViewModel() {
|
||||||
@@ -56,7 +67,11 @@ class HistoryDetailViewModel(private val repo: GymRepository) : ViewModel() {
|
|||||||
uiState.value = DetailUiState.Loading
|
uiState.value = DetailUiState.Loading
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
uiState.value = try {
|
uiState.value = try {
|
||||||
DetailUiState.Ready(repo.fetchSessionDetail(id))
|
DetailUiState.Ready(
|
||||||
|
detail = repo.fetchSessionDetail(id),
|
||||||
|
stats = runCatching { repo.fetchSessionStats(id) }.getOrNull(),
|
||||||
|
bodyWeightKg = repo.bodyWeightKg(),
|
||||||
|
)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
DetailUiState.Error("Kunde inte hämta passet — offline?")
|
DetailUiState.Error("Kunde inte hämta passet — offline?")
|
||||||
}
|
}
|
||||||
@@ -111,35 +126,91 @@ fun HistoryDetailScreen(
|
|||||||
}
|
}
|
||||||
is DetailUiState.Ready -> {
|
is DetailUiState.Ready -> {
|
||||||
val d = state.detail
|
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(
|
LazyColumn(
|
||||||
modifier = Modifier.fillMaxSize().padding(padding),
|
modifier = Modifier.fillMaxSize().padding(padding),
|
||||||
contentPadding = androidx.compose.foundation.layout.PaddingValues(16.dp),
|
contentPadding = androidx.compose.foundation.layout.PaddingValues(16.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||||
) {
|
) {
|
||||||
item {
|
item {
|
||||||
|
Card(modifier = Modifier.fillMaxWidth()) {
|
||||||
|
Column(modifier = Modifier.padding(14.dp)) {
|
||||||
Text(
|
Text(
|
||||||
buildString {
|
buildString {
|
||||||
append(d.date.take(10))
|
append(d.date.take(10))
|
||||||
d.calories?.let { append(" · ${it.toInt()} kcal") }
|
stats?.durationMinutes?.let { append(" · $it min") }
|
||||||
|
if (stats != null) {
|
||||||
|
append(" · ${stats.totalSets} set · ${stats.totalVolumeKg.toInt()} kg volym")
|
||||||
|
}
|
||||||
},
|
},
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
d.notes?.takeIf { it.isNotBlank() }?.let {
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
Text(
|
Text(
|
||||||
it,
|
(stats?.estimatedCalories ?: d.calories)
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
?.let { "${it.toInt()} kcal" }
|
||||||
modifier = Modifier.padding(top = 4.dp),
|
?: "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 ->
|
items(d.exercises) { exercise ->
|
||||||
|
val exerciseKcal = kcalByName[exercise.name]?.removeFirstOrNull()
|
||||||
Card(modifier = Modifier.fillMaxWidth()) {
|
Card(modifier = Modifier.fillMaxWidth()) {
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier.padding(14.dp),
|
modifier = Modifier.padding(14.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||||
) {
|
) {
|
||||||
Text(exercise.name, style = MaterialTheme.typography.titleSmall)
|
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 ->
|
exercise.sets.forEach { set ->
|
||||||
Row {
|
Row {
|
||||||
Text(
|
Text(
|
||||||
|
|||||||
@@ -191,27 +191,16 @@ fun SessionScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (confirmComplete) {
|
if (confirmComplete) {
|
||||||
AlertDialog(
|
val bodyWeight by viewModel.bodyWeightKg.collectAsStateWithLifecycle()
|
||||||
onDismissRequest = { confirmComplete = false },
|
CompleteSessionDialog(
|
||||||
title = { Text("Avsluta passet?") },
|
uiState = uiState,
|
||||||
text = {
|
bodyWeightKg = bodyWeight,
|
||||||
Text(
|
elapsedSeconds = ((nowMs - uiState.startedAtEpochMs) / 1000).toInt().coerceAtLeast(60),
|
||||||
if (uiState.pendingCount > 0) {
|
onConfirm = { durationSeconds ->
|
||||||
"${uiState.pendingCount} ändringar är inte synkade än — de synkas så fort nätet är tillbaka, avsluta lugnt."
|
|
||||||
} else {
|
|
||||||
"Allt är synkat till servern."
|
|
||||||
}
|
|
||||||
)
|
|
||||||
},
|
|
||||||
confirmButton = {
|
|
||||||
Button(onClick = {
|
|
||||||
confirmComplete = false
|
confirmComplete = false
|
||||||
viewModel.completeSession(onSessionEnded)
|
viewModel.completeSession(durationSeconds, onSessionEnded)
|
||||||
}) { Text("Avsluta pass") }
|
|
||||||
},
|
|
||||||
dismissButton = {
|
|
||||||
TextButton(onClick = { confirmComplete = false }) { Text("Fortsätt träna") }
|
|
||||||
},
|
},
|
||||||
|
onDismiss = { confirmComplete = false },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -463,6 +452,114 @@ private fun Stepper(label: String, value: String, onMinus: () -> Unit, onPlus: (
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Avsluta-överblicken: vad du gjort, redigerbar passtid och kcal med förklaring. */
|
||||||
|
@Composable
|
||||||
|
private fun CompleteSessionDialog(
|
||||||
|
uiState: SessionUiState,
|
||||||
|
bodyWeightKg: Double?,
|
||||||
|
elapsedSeconds: Int,
|
||||||
|
onConfirm: (Int) -> Unit,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
) {
|
||||||
|
var durationSeconds by remember { mutableStateOf(elapsedSeconds) }
|
||||||
|
var showCalorieInfo by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
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 ->
|
||||||
|
p.doneSets.sumOf { (it.weight ?: 0.0) * (it.reps ?: 0) }
|
||||||
|
}
|
||||||
|
val exercisesForCalories = uiState.pages
|
||||||
|
.filter { it.doneSets.isNotEmpty() }
|
||||||
|
.map { p ->
|
||||||
|
Triple(
|
||||||
|
p.name,
|
||||||
|
p.type?.metValue ?: 5.0,
|
||||||
|
p.doneSets.sumOf { it.durationSeconds ?: 0 },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val (estimatedKcal, calorieRows) = eu.brassepc.fitnessdroid.ui.common.estimateCalories(
|
||||||
|
exercisesForCalories, bodyWeightKg, durationSeconds,
|
||||||
|
)
|
||||||
|
|
||||||
|
if (showCalorieInfo) {
|
||||||
|
eu.brassepc.fitnessdroid.ui.common.CalorieInfoDialog(
|
||||||
|
rows = calorieRows,
|
||||||
|
bodyWeightKg = bodyWeightKg,
|
||||||
|
totalKcal = estimatedKcal,
|
||||||
|
onDismiss = { showCalorieInfo = false },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
title = { Text("Avsluta ${uiState.sessionName}?") },
|
||||||
|
text = {
|
||||||
|
Column(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")
|
||||||
|
},
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
)
|
||||||
|
|
||||||
|
Column {
|
||||||
|
Text(
|
||||||
|
"PASSTID (justera om du glömde avsluta)",
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||||
|
) {
|
||||||
|
FilledTonalButton(onClick = {
|
||||||
|
durationSeconds = (durationSeconds - 300).coerceAtLeast(60)
|
||||||
|
}) { Text("−5 m") }
|
||||||
|
FilledTonalButton(onClick = {
|
||||||
|
durationSeconds = (durationSeconds - 60).coerceAtLeast(60)
|
||||||
|
}) { Text("−1 m") }
|
||||||
|
Text(
|
||||||
|
"%d:%02d".format(durationSeconds / 3600, (durationSeconds % 3600) / 60),
|
||||||
|
style = MaterialTheme.typography.headlineSmall,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
FilledTonalButton(onClick = { durationSeconds += 60 }) { Text("+1 m") }
|
||||||
|
FilledTonalButton(onClick = { durationSeconds += 300 }) { Text("+5 m") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Text(
|
||||||
|
estimatedKcal?.let { "≈ ${it.toInt()} kcal" }
|
||||||
|
?: "Kalorier: sätt kroppsvikt i profilen",
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
TextButton(onClick = { showCalorieInfo = true }) { Text("Hur räknas detta?") }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (uiState.pendingCount > 0) {
|
||||||
|
Text(
|
||||||
|
"${uiState.pendingCount} ändringar väntar på synk — de skickas så fort nätet är tillbaka.",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
Button(onClick = { onConfirm(durationSeconds) }) { Text("Avsluta pass") }
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = onDismiss) { Text("Fortsätt träna") }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/** Ark med övningens historik: tidigare pass och tyngsta lyften (3 mån). */
|
/** Ark med övningens historik: tidigare pass och tyngsta lyften (3 mån). */
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
|
|||||||
@@ -205,11 +205,18 @@ class SessionViewModel(
|
|||||||
_historySheet.value = null
|
_historySheet.value = null
|
||||||
}
|
}
|
||||||
|
|
||||||
fun completeSession(onDone: () -> Unit) {
|
/** Kroppsvikt för kaloriuppskattningen (cachad från profilen). */
|
||||||
|
val bodyWeightKg = MutableStateFlow<Double?>(null)
|
||||||
|
|
||||||
|
init {
|
||||||
|
viewModelScope.launch { bodyWeightKg.value = repo.bodyWeightKg() }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun completeSession(durationSecondsOverride: Int?, onDone: () -> Unit) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
val id = uiState.value.sessionId ?: sessionFlow.first()?.id ?: return@launch
|
val id = uiState.value.sessionId ?: sessionFlow.first()?.id ?: return@launch
|
||||||
restTimer.dismiss()
|
restTimer.dismiss()
|
||||||
repo.completeSession(id)
|
repo.completeSession(id, durationSecondsOverride)
|
||||||
onDone()
|
onDone()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user