Passdetaljer i historiken + övningshistorik med tyngsta lyft (3 mån)
All checks were successful
release / build-release (push) Successful in 4m48s
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:
@@ -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
|
||||
|
||||
@@ -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) })
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user