Passdetaljer i historiken + övningshistorik med tyngsta lyft (3 mån)
All checks were successful
release / build-release (push) Successful in 4m48s

- Tryck på ett pass i historiken -> detaljvy med övningar och alla set
- Historik-ikon på övningssidan i passläget -> ark med två flikar:
  tidigare pass med övningen (set för set) och tyngsta viktade seten
  senaste tre månaderna (uppvärmningsset exkluderade)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Kf4MAbZ3c3B4Xy5XfuGsCP
This commit is contained in:
2026-07-24 21:44:49 +02:00
parent 0f62741043
commit 68dd0135ab
7 changed files with 530 additions and 13 deletions

View File

@@ -368,6 +368,90 @@ class GymRepository(
}
}
/** Full detalj för ett avslutat pass. */
suspend fun fetchSessionDetail(id: Int): SessionDetail {
val data = client.execute(
"query(\$id:Int!){gymSession(id:\$id){id name notes date startTime estimatedCalories gymSessionExercises{order exerciseType{name} sets{order reps weight distanceMeters durationSeconds rpe isWarmup}}}}",
buildJsonObject { put("id", id) },
auth.bearerToken(),
)
val o = data["gymSession"]!!.jsonObject
return SessionDetail(
id = o["id"]!!.jsonPrimitive.int,
name = o["name"]?.jsonPrimitive?.contentOrNull() ?: "Pass",
date = (o["date"]?.jsonPrimitive?.contentOrNull()
?: o["startTime"]?.jsonPrimitive?.contentOrNull()).orEmpty(),
notes = o["notes"]?.jsonPrimitive?.contentOrNull(),
calories = o["estimatedCalories"]?.jsonPrimitive?.doubleOrNull,
exercises = o["gymSessionExercises"]!!.jsonArray
.sortedBy { it.jsonObject["order"]?.jsonPrimitive?.intOrNull ?: 0 }
.map { e ->
val eo = e.jsonObject
SessionDetailExercise(
name = eo["exerciseType"]?.jsonObject?.get("name")?.jsonPrimitive?.contentOrNull()
?: "Övning",
sets = eo["sets"]!!.jsonArray.map(::parseHistorySet)
.sortedBy { it.order },
)
},
)
}
/**
* Historik för en övning: tidigare pass med dess set, plus tyngsta
* lyften de senaste tre månaderna. Filtreras klient-side ur de
* senaste avslutade passen.
*/
suspend fun fetchExerciseHistory(exerciseTypeId: Int, limit: Int = 40): ExerciseHistory {
val data = client.execute(
"query(\$s:String,\$l:Int){gymSessions(status:\$s,limit:\$l){name date startTime gymSessionExercises{exerciseType{id} sets{order reps weight distanceMeters durationSeconds rpe isWarmup}}}}",
buildJsonObject { put("s", "completed"); put("l", limit) },
auth.bearerToken(),
)
val entries = data["gymSessions"]!!.jsonArray.mapNotNull { s ->
val o = s.jsonObject
val sets = o["gymSessionExercises"]!!.jsonArray
.filter {
it.jsonObject["exerciseType"]?.jsonObject?.get("id")?.jsonPrimitive?.intOrNull == exerciseTypeId
}
.flatMap { it.jsonObject["sets"]!!.jsonArray }
.map(::parseHistorySet)
.sortedBy { it.order }
if (sets.isEmpty()) return@mapNotNull null
ExerciseHistoryEntry(
date = (o["date"]?.jsonPrimitive?.contentOrNull()
?: o["startTime"]?.jsonPrimitive?.contentOrNull()).orEmpty().take(10),
sessionName = o["name"]?.jsonPrimitive?.contentOrNull() ?: "Pass",
sets = sets,
)
}.sortedByDescending { it.date }
val cutoff = java.time.LocalDate.now().minusMonths(3).toString()
val topLifts = entries
.filter { it.date >= cutoff }
.flatMap { entry ->
entry.sets.filter { !it.isWarmup && it.weight != null }
.map { TopLift(entry.date, it.weight!!, it.reps) }
}
.sortedWith(compareByDescending<TopLift> { it.weight }.thenByDescending { it.reps ?: 0 })
.take(10)
return ExerciseHistory(entries = entries, topLifts = topLifts)
}
private fun parseHistorySet(element: kotlinx.serialization.json.JsonElement): HistorySet {
val so = element.jsonObject
return HistorySet(
order = so["order"]?.jsonPrimitive?.intOrNull ?: 0,
weight = so["weight"]?.jsonPrimitive?.doubleOrNull,
reps = so["reps"]?.jsonPrimitive?.intOrNull,
distanceMeters = so["distanceMeters"]?.jsonPrimitive?.doubleOrNull,
durationSeconds = so["durationSeconds"]?.jsonPrimitive?.intOrNull,
rpe = so["rpe"]?.jsonPrimitive?.doubleOrNull,
isWarmup = so["isWarmup"]?.jsonPrimitive?.booleanOrNull ?: false,
)
}
/** Historik hämtas direkt (ingen offline-garanti i v1). */
suspend fun fetchHistory(limit: Int = 30): List<HistoryItem> {
val data = client.execute(
@@ -406,5 +490,42 @@ data class HistoryItem(
val calories: Double?,
)
data class HistorySet(
val order: Int,
val weight: Double?,
val reps: Int?,
val distanceMeters: Double?,
val durationSeconds: Int?,
val rpe: Double?,
val isWarmup: Boolean,
)
data class SessionDetail(
val id: Int,
val name: String,
val date: String,
val notes: String?,
val calories: Double?,
val exercises: List<SessionDetailExercise>,
)
data class SessionDetailExercise(
val name: String,
val sets: List<HistorySet>,
)
data class ExerciseHistoryEntry(
val date: String,
val sessionName: String,
val sets: List<HistorySet>,
)
data class TopLift(val date: String, val weight: Double, val reps: Int?)
data class ExerciseHistory(
val entries: List<ExerciseHistoryEntry>,
val topLifts: List<TopLift>,
)
private fun kotlinx.serialization.json.JsonPrimitive.contentOrNull(): String? =
if (this is kotlinx.serialization.json.JsonNull) null else content

View File

@@ -30,10 +30,13 @@ import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavHostController
import androidx.navigation.NavType
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController
import androidx.navigation.navArgument
import eu.brassepc.fitnessdroid.ui.history.HistoryDetailScreen
import eu.brassepc.fitnessdroid.ui.history.HistoryScreen
import eu.brassepc.fitnessdroid.ui.home.HomeScreen
import eu.brassepc.fitnessdroid.ui.picker.ExercisePickerScreen
@@ -45,11 +48,14 @@ import eu.brassepc.fitnessdroid.ui.stats.StatsScreen
object Routes {
const val HOME = "home"
const val HISTORY = "history"
const val HISTORY_DETAIL = "history/{id}"
const val STATS = "stats"
const val PROFILE = "profile"
const val SETTINGS = "settings"
const val SESSION = "session"
const val PICKER = "picker"
fun historyDetail(id: Int) = "history/$id"
}
private data class TabItem(val route: String, val label: String, val icon: ImageVector)
@@ -101,7 +107,18 @@ fun AppRoot(rootViewModel: RootViewModel = viewModel(factory = RootViewModel.Fac
onOpenHistory = { nav.navigate(Routes.HISTORY) },
)
}
composable(Routes.HISTORY) { HistoryScreen() }
composable(Routes.HISTORY) {
HistoryScreen(onOpenSession = { id -> nav.navigate(Routes.historyDetail(id)) })
}
composable(
Routes.HISTORY_DETAIL,
arguments = listOf(navArgument("id") { type = NavType.IntType }),
) { entry ->
HistoryDetailScreen(
sessionId = entry.arguments?.getInt("id") ?: 0,
onBack = { nav.popBackStack() },
)
}
composable(Routes.STATS) { StatsScreen() }
composable(Routes.PROFILE) {
ProfileScreen(onOpenSettings = { nav.navigate(Routes.SETTINGS) })

View File

@@ -0,0 +1,18 @@
package eu.brassepc.fitnessdroid.ui.common
import eu.brassepc.fitnessdroid.data.HistorySet
fun Double.compact(): String =
if (this % 1.0 == 0.0) toInt().toString() else "%.1f".format(this)
/** "80 kg × 8 · RPE 8" — samma format överallt där set visas. */
fun HistorySet.describe(): String = buildString {
weight?.let { append("${it.compact()} kg") }
reps?.let {
if (isNotEmpty()) append(" × $it") else append("$it reps")
}
durationSeconds?.let { if (isNotEmpty()) append(" · ${it}s") else append("${it}s") }
distanceMeters?.let { if (isNotEmpty()) append(" · ${it.compact()} m") else append("${it.compact()} m") }
rpe?.let { append(" · RPE ${it.compact()}") }
if (isWarmup) append(" · uppvärmning")
}

View File

@@ -0,0 +1,171 @@
package eu.brassepc.fitnessdroid.ui.history
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.Card
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.compose.viewModel
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.ui.appContainer
import eu.brassepc.fitnessdroid.ui.common.describe
import kotlinx.coroutines.flow.MutableStateFlow
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
}
class HistoryDetailViewModel(private val repo: GymRepository) : ViewModel() {
val uiState = MutableStateFlow<DetailUiState>(DetailUiState.Loading)
private var loadedId: Int? = null
fun load(id: Int) {
if (loadedId == id && uiState.value is DetailUiState.Ready) return
loadedId = id
uiState.value = DetailUiState.Loading
viewModelScope.launch {
uiState.value = try {
DetailUiState.Ready(repo.fetchSessionDetail(id))
} catch (e: Exception) {
DetailUiState.Error("Kunde inte hämta passet — offline?")
}
}
}
companion object {
val Factory: ViewModelProvider.Factory = viewModelFactory {
initializer { HistoryDetailViewModel(appContainer().gymRepository) }
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun HistoryDetailScreen(
sessionId: Int,
onBack: () -> Unit,
viewModel: HistoryDetailViewModel = viewModel(factory = HistoryDetailViewModel.Factory),
) {
LaunchedEffect(sessionId) { viewModel.load(sessionId) }
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
Scaffold(
topBar = {
TopAppBar(
title = {
Text((uiState as? DetailUiState.Ready)?.detail?.name ?: "Pass")
},
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Tillbaka")
}
},
)
},
) { padding ->
when (val state = uiState) {
is DetailUiState.Loading -> {
Box(
modifier = Modifier.fillMaxSize().padding(padding),
contentAlignment = Alignment.Center,
) { CircularProgressIndicator() }
}
is DetailUiState.Error -> {
Box(
modifier = Modifier.fillMaxSize().padding(padding),
contentAlignment = Alignment.Center,
) {
Text(state.message, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
is DetailUiState.Ready -> {
val d = state.detail
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),
)
}
}
items(d.exercises) { exercise ->
Card(modifier = Modifier.fillMaxWidth()) {
Column(
modifier = Modifier.padding(14.dp),
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
Text(exercise.name, style = MaterialTheme.typography.titleSmall)
exercise.sets.forEach { set ->
Row {
Text(
"Set ${set.order}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(end = 10.dp),
)
Text(
set.describe(),
style = MaterialTheme.typography.bodySmall,
)
}
}
if (exercise.sets.isEmpty()) {
Text(
"Inga set loggade",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
}
}
}
}
}

View File

@@ -61,8 +61,12 @@ class HistoryViewModel(private val repo: GymRepository) : ViewModel() {
}
}
@OptIn(androidx.compose.material3.ExperimentalMaterial3Api::class)
@Composable
fun HistoryScreen(viewModel: HistoryViewModel = viewModel(factory = HistoryViewModel.Factory)) {
fun HistoryScreen(
onOpenSession: (Int) -> Unit,
viewModel: HistoryViewModel = viewModel(factory = HistoryViewModel.Factory),
) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
when (val state = uiState) {
@@ -93,7 +97,10 @@ fun HistoryScreen(viewModel: HistoryViewModel = viewModel(factory = HistoryViewM
Text("Passhistorik", style = MaterialTheme.typography.headlineSmall)
}
items(state.items, key = { it.id }) { item ->
Card(modifier = Modifier.fillMaxWidth()) {
Card(
onClick = { onOpenSession(item.id) },
modifier = Modifier.fillMaxWidth(),
) {
Column(modifier = Modifier.padding(14.dp)) {
Row {
Text(

View File

@@ -9,6 +9,7 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.pager.HorizontalPager
@@ -22,7 +23,9 @@ import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.CloudDone
import androidx.compose.material.icons.filled.CloudUpload
import androidx.compose.material.icons.filled.EmojiEvents
import androidx.compose.material.icons.filled.Flag
import androidx.compose.material.icons.filled.History
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.Card
@@ -54,6 +57,7 @@ import androidx.lifecycle.viewmodel.compose.viewModel
import eu.brassepc.fitnessdroid.data.RestState
import eu.brassepc.fitnessdroid.data.RestTimerStyle
import eu.brassepc.fitnessdroid.data.local.LocalSet
import eu.brassepc.fitnessdroid.ui.common.describe
import kotlinx.coroutines.delay
@OptIn(ExperimentalMaterial3Api::class)
@@ -140,6 +144,7 @@ fun SessionScreen(
onDraft = viewModel::updateDraft,
onLog = viewModel::logSet,
onEditSet = { page, set -> editTarget = page to set },
onOpenHistory = viewModel::openExerciseHistory,
)
}
FilledTonalButton(
@@ -164,6 +169,11 @@ fun SessionScreen(
}
}
val historySheet by viewModel.historySheet.collectAsStateWithLifecycle()
historySheet?.let { sheet ->
ExerciseHistorySheet(state = sheet, onDismiss = viewModel::closeExerciseHistory)
}
editTarget?.let { (page, set) ->
EditSetDialog(
page = page,
@@ -262,6 +272,7 @@ private fun ExercisePageContent(
onDraft: (Long, (SetDraft) -> SetDraft) -> Unit,
onLog: (Long) -> Unit,
onEditSet: (ExercisePage, LocalSet) -> Unit,
onOpenHistory: (ExercisePage) -> Unit,
) {
Column(
modifier = Modifier
@@ -270,7 +281,8 @@ private fun ExercisePageContent(
.padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
Column {
Row(verticalAlignment = Alignment.CenterVertically) {
Column(modifier = Modifier.weight(1f)) {
Text(page.name, style = MaterialTheme.typography.headlineSmall)
Text(
buildString {
@@ -281,6 +293,14 @@ private fun ExercisePageContent(
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
IconButton(onClick = { onOpenHistory(page) }) {
Icon(
Icons.Default.History,
contentDescription = "Övningshistorik",
tint = MaterialTheme.colorScheme.primary,
)
}
}
page.doneSets.forEach { set -> DoneSetRow(set, onClick = { onEditSet(page, set) }) }
@@ -443,6 +463,138 @@ private fun Stepper(label: String, value: String, onMinus: () -> Unit, onPlus: (
}
}
/** Ark med övningens historik: tidigare pass och tyngsta lyften (3 mån). */
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun ExerciseHistorySheet(
state: SessionViewModel.HistorySheetState,
onDismiss: () -> Unit,
) {
androidx.compose.material3.ModalBottomSheet(onDismissRequest = onDismiss) {
var tab by remember { mutableStateOf(0) }
Column(modifier = Modifier.padding(bottom = 24.dp)) {
Text(
when (state) {
is SessionViewModel.HistorySheetState.Loading -> state.exerciseName
is SessionViewModel.HistorySheetState.Error -> state.exerciseName
is SessionViewModel.HistorySheetState.Ready -> state.exerciseName
},
style = MaterialTheme.typography.titleLarge,
modifier = Modifier.padding(horizontal = 20.dp, vertical = 4.dp),
)
androidx.compose.material3.TabRow(selectedTabIndex = tab) {
androidx.compose.material3.Tab(
selected = tab == 0,
onClick = { tab = 0 },
text = { Text("Historik") },
)
androidx.compose.material3.Tab(
selected = tab == 1,
onClick = { tab = 1 },
text = { Text("Tyngsta · 3 mån") },
)
}
when (state) {
is SessionViewModel.HistorySheetState.Loading -> {
Box(
modifier = Modifier.fillMaxWidth().padding(40.dp),
contentAlignment = Alignment.Center,
) { CircularProgressIndicator() }
}
is SessionViewModel.HistorySheetState.Error -> {
Text(
"Kunde inte hämta historiken — offline?",
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(20.dp),
)
}
is SessionViewModel.HistorySheetState.Ready -> {
val history = state.history
androidx.compose.foundation.lazy.LazyColumn(
modifier = Modifier.fillMaxWidth().heightIn(max = 420.dp),
contentPadding = androidx.compose.foundation.layout.PaddingValues(
horizontal = 20.dp, vertical = 12.dp,
),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
if (tab == 0) {
if (history.entries.isEmpty()) {
item {
Text(
"Inga tidigare pass med den här övningen.",
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
items(history.entries.size) { i ->
val entry = history.entries[i]
Column {
Text(
"${entry.date} · ${entry.sessionName}",
style = MaterialTheme.typography.titleSmall,
)
entry.sets.forEach { set ->
Text(
"Set ${set.order}: ${set.describe()}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
} else {
if (history.topLifts.isEmpty()) {
item {
Text(
"Inga viktade set de senaste tre månaderna.",
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
items(history.topLifts.size) { i ->
val lift = history.topLifts[i]
Row(verticalAlignment = Alignment.CenterVertically) {
if (i == 0) {
Icon(
Icons.Default.EmojiEvents,
contentDescription = "Tyngst",
tint = MaterialTheme.colorScheme.tertiary,
modifier = Modifier
.size(18.dp)
.padding(end = 2.dp),
)
} else {
Text(
"${i + 1}.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(end = 6.dp),
)
}
Text(
buildString {
append("${lift.weight.compact()} kg")
lift.reps?.let { append(" × $it") }
},
style = MaterialTheme.typography.titleSmall,
modifier = Modifier.weight(1f),
)
Text(
lift.date,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
}
}
}
}
}
/** Rätta ett redan loggat set: samma steppers som vid loggning + ta bort. */
@Composable
private fun EditSetDialog(

View File

@@ -16,6 +16,7 @@ import eu.brassepc.fitnessdroid.ui.appContainer
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flatMapLatest
@@ -174,6 +175,36 @@ class SessionViewModel(
viewModelScope.launch { repo.deleteSet(setId) }
}
/* ---------- Övningshistorik-arket ---------- */
sealed interface HistorySheetState {
data class Loading(val exerciseName: String) : HistorySheetState
data class Error(val exerciseName: String) : HistorySheetState
data class Ready(
val exerciseName: String,
val history: eu.brassepc.fitnessdroid.data.ExerciseHistory,
) : HistorySheetState
}
private val _historySheet = MutableStateFlow<HistorySheetState?>(null)
val historySheet = _historySheet.asStateFlow()
fun openExerciseHistory(page: ExercisePage) {
val typeId = page.type?.id ?: return
_historySheet.value = HistorySheetState.Loading(page.name)
viewModelScope.launch {
_historySheet.value = try {
HistorySheetState.Ready(page.name, repo.fetchExerciseHistory(typeId))
} catch (e: Exception) {
HistorySheetState.Error(page.name)
}
}
}
fun closeExerciseHistory() {
_historySheet.value = null
}
fun completeSession(onDone: () -> Unit) {
viewModelScope.launch {
val id = uiState.value.sessionId ?: sessionFlow.first()?.id ?: return@launch