diff --git a/app/src/main/java/eu/brassepc/fitnessdroid/FitnessDroidApplication.kt b/app/src/main/java/eu/brassepc/fitnessdroid/FitnessDroidApplication.kt index 8c85236..380309b 100644 --- a/app/src/main/java/eu/brassepc/fitnessdroid/FitnessDroidApplication.kt +++ b/app/src/main/java/eu/brassepc/fitnessdroid/FitnessDroidApplication.kt @@ -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) } 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 dc3ec4e..a29d4cb 100644 --- a/app/src/main/java/eu/brassepc/fitnessdroid/data/GymRepository.kt +++ b/app/src/main/java/eu/brassepc/fitnessdroid/data/GymRepository.kt @@ -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 = 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 { val data = client.execute( @@ -527,5 +567,22 @@ data class ExerciseHistory( val topLifts: List, ) +data class SessionStats( + val durationMinutes: Int?, + val estimatedCalories: Double?, + val totalSets: Int, + val totalReps: Int, + val totalVolumeKg: Double, + val breakdown: List, +) + +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 diff --git a/app/src/main/java/eu/brassepc/fitnessdroid/data/SyncEngine.kt b/app/src/main/java/eu/brassepc/fitnessdroid/data/SyncEngine.kt index 7bae1df..73dd325 100644 --- a/app/src/main/java/eu/brassepc/fitnessdroid/data/SyncEngine.kt +++ b/app/src/main/java/eu/brassepc/fitnessdroid/data/SyncEngine.kt @@ -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}}", diff --git a/app/src/main/java/eu/brassepc/fitnessdroid/data/TokenStore.kt b/app/src/main/java/eu/brassepc/fitnessdroid/data/TokenStore.kt index 86c9ca4..ea9fe34 100644 --- a/app/src/main/java/eu/brassepc/fitnessdroid/data/TokenStore.kt +++ b/app/src/main/java/eu/brassepc/fitnessdroid/data/TokenStore.kt @@ -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") } } diff --git a/app/src/main/java/eu/brassepc/fitnessdroid/data/local/AppDatabase.kt b/app/src/main/java/eu/brassepc/fitnessdroid/data/local/AppDatabase.kt index 8a17b6b..3b61e35 100644 --- a/app/src/main/java/eu/brassepc/fitnessdroid/data/local/AppDatabase.kt +++ b/app/src/main/java/eu/brassepc/fitnessdroid/data/local/AppDatabase.kt @@ -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() } diff --git a/app/src/main/java/eu/brassepc/fitnessdroid/data/local/Entities.kt b/app/src/main/java/eu/brassepc/fitnessdroid/data/local/Entities.kt index b10cb58..64eeea4 100644 --- a/app/src/main/java/eu/brassepc/fitnessdroid/data/local/Entities.kt +++ b/app/src/main/java/eu/brassepc/fitnessdroid/data/local/Entities.kt @@ -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( diff --git a/app/src/main/java/eu/brassepc/fitnessdroid/ui/common/CalorieInfo.kt b/app/src/main/java/eu/brassepc/fitnessdroid/ui/common/CalorieInfo.kt new file mode 100644 index 0000000..2ff2670 --- /dev/null +++ b/app/src/main/java/eu/brassepc/fitnessdroid/ui/common/CalorieInfo.kt @@ -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>, // (namn, MET, golv-sekunder från tidssatta set) + bodyWeightKg: Double?, + totalSeconds: Int, +): Pair> { + 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, + 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") } + }, + ) +} 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 index b845a31..f11801e 100644 --- a/app/src/main/java/eu/brassepc/fitnessdroid/ui/history/HistoryDetailScreen.kt +++ b/app/src/main/java/eu/brassepc/fitnessdroid/ui/history/HistoryDetailScreen.kt @@ -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( 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 ba91020..7d44a4e 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 @@ -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 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 b6aba06..4588de7 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 @@ -205,11 +205,18 @@ class SessionViewModel( _historySheet.value = null } - fun completeSession(onDone: () -> Unit) { + /** Kroppsvikt för kaloriuppskattningen (cachad från profilen). */ + val bodyWeightKg = MutableStateFlow(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() } }