Avsluta-överblick med redigerbar passtid + kcal-summering med uträkning
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:
2026-07-25 10:13:10 +02:00
parent 68dd0135ab
commit 214e12d2e2
10 changed files with 422 additions and 43 deletions

View File

@@ -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)
} }

View File

@@ -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

View File

@@ -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}}",

View File

@@ -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")
} }
} }

View File

@@ -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()
} }

View File

@@ -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(

View File

@@ -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") }
},
)
}

View File

@@ -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 {
Text( Card(modifier = Modifier.fillMaxWidth()) {
buildString { Column(modifier = Modifier.padding(14.dp)) {
append(d.date.take(10)) Text(
d.calories?.let { append(" · ${it.toInt()} kcal") } buildString {
}, append(d.date.take(10))
style = MaterialTheme.typography.bodyMedium, stats?.durationMinutes?.let { append(" · $it min") }
color = MaterialTheme.colorScheme.onSurfaceVariant, if (stats != null) {
) append(" · ${stats.totalSets} set · ${stats.totalVolumeKg.toInt()} kg volym")
d.notes?.takeIf { it.isNotBlank() }?.let { }
Text( },
it, style = MaterialTheme.typography.bodyMedium,
style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 4.dp), )
) 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 -> 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(

View File

@@ -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." confirmComplete = false
} else { viewModel.completeSession(durationSeconds, onSessionEnded)
"Allt är synkat till servern."
}
)
},
confirmButton = {
Button(onClick = {
confirmComplete = false
viewModel.completeSession(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

View File

@@ -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()
} }
} }