Avsluta-överblick med redigerbar passtid + kcal-summering med uträkning
All checks were successful
release / build-release (push) Successful in 5m37s
All checks were successful
release / build-release (push) Successful in 5m37s
- Avsluta-dialogen visar övningar/set/reps/volym, passtiden går att justera (±1/±5 min, skickas som durationSecondsOverride — för alla gånger man glömt avsluta) och en kcal-uppskattning som räknas om live med tiden - 'Hur räknas detta?' visar MET-formeln (MET × kroppsvikt × timmar) med siffrorna per övning; kroppsvikten cachas från profilen - Passdetaljen i historiken: summeringskort (tid, set, volym, kcal) + samma förklaringsdialog med serverns faktiska fördelning, och kcal per övning på varje övningskort - Room v2-migration för durationSecondsOverride Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Kf4MAbZ3c3B4Xy5XfuGsCP
This commit is contained in:
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -30,6 +30,7 @@ class GymRepository(
|
||||
private val client: GraphQlClient,
|
||||
private val auth: AuthRepository,
|
||||
private val sync: SyncEngine,
|
||||
private val store: TokenStore,
|
||||
private val scope: CoroutineScope,
|
||||
) {
|
||||
val activeSession = db.sessionDao().activeSession()
|
||||
@@ -141,12 +142,16 @@ class GymRepository(
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun completeSession(sessionId: Long) {
|
||||
suspend fun completeSession(sessionId: Long, durationSecondsOverride: Int? = null) {
|
||||
val session = db.sessionDao().session(sessionId) ?: return
|
||||
db.sessionDao().updateSession(session.copy(status = "completed"))
|
||||
db.sessionDao().updateSession(
|
||||
session.copy(status = "completed", durationSecondsOverride = durationSecondsOverride)
|
||||
)
|
||||
enqueue(OpKind.COMPLETE_SESSION, sessionId)
|
||||
}
|
||||
|
||||
suspend fun bodyWeightKg(): Double? = store.bodyWeightKg()
|
||||
|
||||
/* ---------- Referensdata / cache ---------- */
|
||||
|
||||
/** Hämta om all referensdata. Tyst vid nätfel — cachen gäller tills vidare. */
|
||||
@@ -196,6 +201,13 @@ class GymRepository(
|
||||
)
|
||||
}
|
||||
|
||||
runCatching {
|
||||
val prof = client.execute("query{myProfile{bodyWeightKg}}", token = token)
|
||||
store.saveBodyWeight(
|
||||
prof["myProfile"]?.jsonObject?.get("bodyWeightKg")?.jsonPrimitive?.doubleOrNull
|
||||
)
|
||||
}
|
||||
|
||||
val favIds: Set<Int> = runCatching {
|
||||
client.execute("query{favoriteExercises{id}}", token = token)["favoriteExercises"]!!
|
||||
.jsonArray.map { it.jsonObject["id"]!!.jsonPrimitive.int }.toSet()
|
||||
@@ -452,6 +464,34 @@ class GymRepository(
|
||||
)
|
||||
}
|
||||
|
||||
/** Serverns kaloriberäkning och tidsfördelning för ett pass. */
|
||||
suspend fun fetchSessionStats(id: Int): SessionStats {
|
||||
val data = client.execute(
|
||||
"query(\$id:Int!){gymSessionStats(id:\$id){durationMinutes estimatedCalories totalSets totalReps totalVolumeKg exerciseBreakdown{exerciseTypeId exerciseName allocatedSeconds estimatedCalories totalSets totalReps maxWeight volumeKg}}}",
|
||||
buildJsonObject { put("id", id) },
|
||||
auth.bearerToken(),
|
||||
)
|
||||
val o = data["gymSessionStats"]!!.jsonObject
|
||||
return SessionStats(
|
||||
durationMinutes = o["durationMinutes"]?.jsonPrimitive?.intOrNull,
|
||||
estimatedCalories = o["estimatedCalories"]?.jsonPrimitive?.doubleOrNull,
|
||||
totalSets = o["totalSets"]?.jsonPrimitive?.intOrNull ?: 0,
|
||||
totalReps = o["totalReps"]?.jsonPrimitive?.intOrNull ?: 0,
|
||||
totalVolumeKg = o["totalVolumeKg"]?.jsonPrimitive?.doubleOrNull ?: 0.0,
|
||||
breakdown = o["exerciseBreakdown"]?.jsonArray?.map { e ->
|
||||
val eo = e.jsonObject
|
||||
val typeId = eo["exerciseTypeId"]?.jsonPrimitive?.intOrNull
|
||||
ExerciseStats(
|
||||
exerciseTypeId = typeId,
|
||||
name = eo["exerciseName"]?.jsonPrimitive?.contentOrNull() ?: "Övning",
|
||||
allocatedSeconds = eo["allocatedSeconds"]?.jsonPrimitive?.intOrNull,
|
||||
estimatedCalories = eo["estimatedCalories"]?.jsonPrimitive?.doubleOrNull,
|
||||
metValue = typeId?.let { db.cacheDao().exerciseType(it)?.metValue },
|
||||
)
|
||||
}.orEmpty(),
|
||||
)
|
||||
}
|
||||
|
||||
/** Historik hämtas direkt (ingen offline-garanti i v1). */
|
||||
suspend fun fetchHistory(limit: Int = 30): List<HistoryItem> {
|
||||
val data = client.execute(
|
||||
@@ -527,5 +567,22 @@ data class ExerciseHistory(
|
||||
val topLifts: List<TopLift>,
|
||||
)
|
||||
|
||||
data class SessionStats(
|
||||
val durationMinutes: Int?,
|
||||
val estimatedCalories: Double?,
|
||||
val totalSets: Int,
|
||||
val totalReps: Int,
|
||||
val totalVolumeKg: Double,
|
||||
val breakdown: List<ExerciseStats>,
|
||||
)
|
||||
|
||||
data class ExerciseStats(
|
||||
val exerciseTypeId: Int?,
|
||||
val name: String,
|
||||
val allocatedSeconds: Int?,
|
||||
val estimatedCalories: Double?,
|
||||
val metValue: Double?,
|
||||
)
|
||||
|
||||
private fun kotlinx.serialization.json.JsonPrimitive.contentOrNull(): String? =
|
||||
if (this is kotlinx.serialization.json.JsonNull) null else content
|
||||
|
||||
@@ -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}}",
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
package eu.brassepc.fitnessdroid.ui.common
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
/** En rad i kaloriuträkningen: en övning med MET, tilldelad tid och kcal. */
|
||||
data class CalorieRow(
|
||||
val name: String,
|
||||
val metValue: Double?,
|
||||
val seconds: Int,
|
||||
val kcal: Double?,
|
||||
)
|
||||
|
||||
/**
|
||||
* Klient-side-uppskattning som speglar serverns ComputeCalories:
|
||||
* passets tid fördelas jämnt över övningarna (tidssatta set utgör golv
|
||||
* för sin övning) och per övning gäller kcal = MET × kroppsvikt × timmar.
|
||||
*/
|
||||
fun estimateCalories(
|
||||
exercises: List<Triple<String, Double, Int>>, // (namn, MET, golv-sekunder från tidssatta set)
|
||||
bodyWeightKg: Double?,
|
||||
totalSeconds: Int,
|
||||
): Pair<Double?, List<CalorieRow>> {
|
||||
if (exercises.isEmpty()) return null to emptyList()
|
||||
val floorsSum = exercises.sumOf { it.third }
|
||||
val leftover = (totalSeconds - floorsSum).coerceAtLeast(0)
|
||||
val perAuto = leftover / exercises.size.toDouble()
|
||||
val rows = exercises.map { (name, met, floor) ->
|
||||
val sec = (floor + perAuto).toInt()
|
||||
CalorieRow(
|
||||
name = name,
|
||||
metValue = met,
|
||||
seconds = sec,
|
||||
kcal = bodyWeightKg?.let { met * it * (sec / 3600.0) },
|
||||
)
|
||||
}
|
||||
val total = if (bodyWeightKg == null) null else rows.sumOf { it.kcal ?: 0.0 }
|
||||
return total to rows
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun CalorieInfoDialog(
|
||||
rows: List<CalorieRow>,
|
||||
bodyWeightKg: Double?,
|
||||
totalKcal: Double?,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text("Så räknas kalorierna") },
|
||||
text = {
|
||||
Column(
|
||||
modifier = Modifier.verticalScroll(rememberScrollState()),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
Text(
|
||||
"Per övning: kcal = MET × kroppsvikt (kg) × tid (timmar). " +
|
||||
"Passets tid fördelas jämnt över övningarna, men set med " +
|
||||
"uppmätt tid räknas alltid in i sin övning.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
if (bodyWeightKg == null) {
|
||||
Text(
|
||||
"Ingen kroppsvikt satt i profilen — därför kan inga " +
|
||||
"kalorier beräknas. Sätt den på webben under Profil.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
} else {
|
||||
Text(
|
||||
"Din kroppsvikt: ${bodyWeightKg.compact()} kg",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
rows.forEach { row ->
|
||||
Row(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(row.name, style = MaterialTheme.typography.bodyMedium)
|
||||
Text(
|
||||
buildString {
|
||||
append("MET ${row.metValue?.compact() ?: "?"}")
|
||||
append(" · ${row.seconds / 60} min")
|
||||
},
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
Text(
|
||||
row.kcal?.let { "${it.toInt()} kcal" } ?: "—",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
}
|
||||
}
|
||||
if (totalKcal != null) {
|
||||
Row(modifier = Modifier.fillMaxWidth().padding(top = 4.dp)) {
|
||||
Text(
|
||||
"Totalt",
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
Text("${totalKcal.toInt()} kcal", style = MaterialTheme.typography.titleSmall)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = onDismiss) { Text("Stäng") }
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -205,11 +205,18 @@ class SessionViewModel(
|
||||
_historySheet.value = null
|
||||
}
|
||||
|
||||
fun completeSession(onDone: () -> Unit) {
|
||||
/** Kroppsvikt för kaloriuppskattningen (cachad från profilen). */
|
||||
val bodyWeightKg = MutableStateFlow<Double?>(null)
|
||||
|
||||
init {
|
||||
viewModelScope.launch { bodyWeightKg.value = repo.bodyWeightKg() }
|
||||
}
|
||||
|
||||
fun completeSession(durationSecondsOverride: Int?, onDone: () -> Unit) {
|
||||
viewModelScope.launch {
|
||||
val id = uiState.value.sessionId ?: sessionFlow.first()?.id ?: return@launch
|
||||
restTimer.dismiss()
|
||||
repo.completeSession(id)
|
||||
repo.completeSession(id, durationSecondsOverride)
|
||||
onDone()
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user