diff --git a/app/src/main/java/eu/brassepc/fitnessdroid/data/GymRepository.kt b/app/src/main/java/eu/brassepc/fitnessdroid/data/GymRepository.kt index ed939f2..dc3ec4e 100644 --- a/app/src/main/java/eu/brassepc/fitnessdroid/data/GymRepository.kt +++ b/app/src/main/java/eu/brassepc/fitnessdroid/data/GymRepository.kt @@ -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 { 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 { 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, +) + +data class SessionDetailExercise( + val name: String, + val sets: List, +) + +data class ExerciseHistoryEntry( + val date: String, + val sessionName: String, + val sets: List, +) + +data class TopLift(val date: String, val weight: Double, val reps: Int?) + +data class ExerciseHistory( + val entries: List, + val topLifts: List, +) + private fun kotlinx.serialization.json.JsonPrimitive.contentOrNull(): String? = if (this is kotlinx.serialization.json.JsonNull) null else content diff --git a/app/src/main/java/eu/brassepc/fitnessdroid/ui/AppRoot.kt b/app/src/main/java/eu/brassepc/fitnessdroid/ui/AppRoot.kt index 8e278d2..978038b 100644 --- a/app/src/main/java/eu/brassepc/fitnessdroid/ui/AppRoot.kt +++ b/app/src/main/java/eu/brassepc/fitnessdroid/ui/AppRoot.kt @@ -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) }) diff --git a/app/src/main/java/eu/brassepc/fitnessdroid/ui/common/Format.kt b/app/src/main/java/eu/brassepc/fitnessdroid/ui/common/Format.kt new file mode 100644 index 0000000..47853fa --- /dev/null +++ b/app/src/main/java/eu/brassepc/fitnessdroid/ui/common/Format.kt @@ -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") +} diff --git a/app/src/main/java/eu/brassepc/fitnessdroid/ui/history/HistoryDetailScreen.kt b/app/src/main/java/eu/brassepc/fitnessdroid/ui/history/HistoryDetailScreen.kt new file mode 100644 index 0000000..b845a31 --- /dev/null +++ b/app/src/main/java/eu/brassepc/fitnessdroid/ui/history/HistoryDetailScreen.kt @@ -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.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, + ) + } + } + } + } + } + } + } + } +} diff --git a/app/src/main/java/eu/brassepc/fitnessdroid/ui/history/HistoryScreen.kt b/app/src/main/java/eu/brassepc/fitnessdroid/ui/history/HistoryScreen.kt index 73e6bf8..16f2e7c 100644 --- a/app/src/main/java/eu/brassepc/fitnessdroid/ui/history/HistoryScreen.kt +++ b/app/src/main/java/eu/brassepc/fitnessdroid/ui/history/HistoryScreen.kt @@ -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( diff --git a/app/src/main/java/eu/brassepc/fitnessdroid/ui/session/SessionScreen.kt b/app/src/main/java/eu/brassepc/fitnessdroid/ui/session/SessionScreen.kt index e0f330f..ba91020 100644 --- a/app/src/main/java/eu/brassepc/fitnessdroid/ui/session/SessionScreen.kt +++ b/app/src/main/java/eu/brassepc/fitnessdroid/ui/session/SessionScreen.kt @@ -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,16 +281,25 @@ private fun ExercisePageContent( .padding(horizontal = 16.dp), verticalArrangement = Arrangement.spacedBy(10.dp), ) { - Column { - Text(page.name, style = MaterialTheme.typography.headlineSmall) - Text( - buildString { - append("Övning ${pageIndex + 1} av $pageCount") - page.lastPerformanceText?.let { append(" · $it") } - }, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) + Row(verticalAlignment = Alignment.CenterVertically) { + Column(modifier = Modifier.weight(1f)) { + Text(page.name, style = MaterialTheme.typography.headlineSmall) + Text( + buildString { + append("Övning ${pageIndex + 1} av $pageCount") + page.lastPerformanceText?.let { append(" · $it") } + }, + style = MaterialTheme.typography.bodySmall, + 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( diff --git a/app/src/main/java/eu/brassepc/fitnessdroid/ui/session/SessionViewModel.kt b/app/src/main/java/eu/brassepc/fitnessdroid/ui/session/SessionViewModel.kt index a720caf..b6aba06 100644 --- a/app/src/main/java/eu/brassepc/fitnessdroid/ui/session/SessionViewModel.kt +++ b/app/src/main/java/eu/brassepc/fitnessdroid/ui/session/SessionViewModel.kt @@ -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(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