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 database = AppDatabase.build(context)
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)
}

View File

@@ -30,6 +30,7 @@ class GymRepository(
private val client: GraphQlClient,
private val auth: AuthRepository,
private val sync: SyncEngine,
private val store: TokenStore,
private val scope: CoroutineScope,
) {
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
db.sessionDao().updateSession(session.copy(status = "completed"))
db.sessionDao().updateSession(
session.copy(status = "completed", durationSecondsOverride = durationSecondsOverride)
)
enqueue(OpKind.COMPLETE_SESSION, sessionId)
}
suspend fun bodyWeightKg(): Double? = store.bodyWeightKg()
/* ---------- Referensdata / cache ---------- */
/** 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 {
client.execute("query{favoriteExercises{id}}", token = token)["favoriteExercises"]!!
.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). */
suspend fun fetchHistory(limit: Int = 30): List<HistoryItem> {
val data = client.execute(
@@ -527,5 +567,22 @@ data class ExerciseHistory(
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? =
if (this is kotlinx.serialization.json.JsonNull) null else content

View File

@@ -186,6 +186,7 @@ class SyncEngine(
val input = buildJsonObject {
put("id", sessionServerId)
session.name?.let { put("name", it) }
session.durationSecondsOverride?.let { put("durationSecondsOverride", it) }
}
client.execute(
"mutation(\$input: CompleteGymSessionInput!){completeGymSession(input:\$input){id status}}",

View File

@@ -1,6 +1,7 @@
package eu.brassepc.fitnessdroid.data
import android.content.Context
import androidx.datastore.preferences.core.doublePreferencesKey
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.longPreferencesKey
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. */
suspend fun clearSession() {
context.authDataStore.edit {
@@ -83,5 +94,6 @@ class TokenStore(private val context: Context) {
private val KEY_REFRESH_TOKEN = stringPreferencesKey("refresh_token")
private val KEY_EXPIRATION = longPreferencesKey("expiration_epoch_ms")
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.Room
import androidx.room.RoomDatabase
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
@Database(
entities = [
@@ -16,7 +18,7 @@ import androidx.room.RoomDatabase
CachedMuscle::class,
CachedStartCard::class,
],
version = 1,
version = 2,
exportSchema = false,
)
abstract class AppDatabase : RoomDatabase() {
@@ -25,8 +27,15 @@ abstract class AppDatabase : RoomDatabase() {
abstract fun cacheDao(): CacheDao
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 =
Room.databaseBuilder(context, AppDatabase::class.java, "fitnessdroid.db")
.addMigrations(MIGRATION_1_2)
.fallbackToDestructiveMigration()
.build()
}

View File

@@ -19,6 +19,8 @@ data class LocalSession(
val startedAtEpochMs: Long,
/** "active" eller "completed" (lokalt avslutad, ev. ej synkad än) */
val status: String = "active",
/** Manuellt rättad passtid — skickas som durationSecondsOverride vid avslut */
val durationSecondsOverride: Int? = null,
)
@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.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
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.Modifier
import androidx.compose.ui.unit.dp
@@ -35,7 +39,10 @@ import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory
import eu.brassepc.fitnessdroid.data.GymRepository
import eu.brassepc.fitnessdroid.data.SessionDetail
import eu.brassepc.fitnessdroid.data.SessionStats
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 kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.launch
@@ -43,7 +50,11 @@ import kotlinx.coroutines.launch
sealed interface DetailUiState {
data object Loading : 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() {
@@ -56,7 +67,11 @@ class HistoryDetailViewModel(private val repo: GymRepository) : ViewModel() {
uiState.value = DetailUiState.Loading
viewModelScope.launch {
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) {
DetailUiState.Error("Kunde inte hämta passet — offline?")
}
@@ -111,35 +126,91 @@ fun HistoryDetailScreen(
}
is DetailUiState.Ready -> {
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(
modifier = Modifier.fillMaxSize().padding(padding),
contentPadding = androidx.compose.foundation.layout.PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
item {
Text(
buildString {
append(d.date.take(10))
d.calories?.let { append(" · ${it.toInt()} kcal") }
},
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
d.notes?.takeIf { it.isNotBlank() }?.let {
Text(
it,
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(top = 4.dp),
)
Card(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.padding(14.dp)) {
Text(
buildString {
append(d.date.take(10))
stats?.durationMinutes?.let { append(" · $it min") }
if (stats != null) {
append(" · ${stats.totalSets} set · ${stats.totalVolumeKg.toInt()} kg volym")
}
},
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
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 ->
val exerciseKcal = kcalByName[exercise.name]?.removeFirstOrNull()
Card(modifier = Modifier.fillMaxWidth()) {
Column(
modifier = Modifier.padding(14.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 ->
Row {
Text(

View File

@@ -191,27 +191,16 @@ fun SessionScreen(
}
if (confirmComplete) {
AlertDialog(
onDismissRequest = { confirmComplete = false },
title = { Text("Avsluta passet?") },
text = {
Text(
if (uiState.pendingCount > 0) {
"${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
viewModel.completeSession(onSessionEnded)
}) { Text("Avsluta pass") }
},
dismissButton = {
TextButton(onClick = { confirmComplete = false }) { Text("Fortsätt träna") }
val bodyWeight by viewModel.bodyWeightKg.collectAsStateWithLifecycle()
CompleteSessionDialog(
uiState = uiState,
bodyWeightKg = bodyWeight,
elapsedSeconds = ((nowMs - uiState.startedAtEpochMs) / 1000).toInt().coerceAtLeast(60),
onConfirm = { durationSeconds ->
confirmComplete = false
viewModel.completeSession(durationSeconds, onSessionEnded)
},
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). */
@OptIn(ExperimentalMaterial3Api::class)
@Composable

View File

@@ -205,11 +205,18 @@ class SessionViewModel(
_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 {
val id = uiState.value.sessionId ?: sessionFlow.first()?.id ?: return@launch
restTimer.dismiss()
repo.completeSession(id)
repo.completeSession(id, durationSecondsOverride)
onDone()
}
}