Avsluta-dialogen: redigerbar start (datum + klockslag) och varaktighet
All checks were successful
release / build-release (push) Successful in 4m58s

- Startdatum/-tid väljs med date-/timepicker; varaktigheten med steppers,
  och den beräknade sluttiden visas — täcker både 'startade passet mitt i
  träningen' och 'glömde avsluta till dagen efter'
- Förifylld varaktighet ankras i senast loggade setet istället för 'nu',
  så ett bortglömt pass inte föreslår 14 timmar
- Rättad start synkas som updateGymSession(date) före completeGymSession
- Room v3-migration för editedStartEpochMs

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Kf4MAbZ3c3B4Xy5XfuGsCP
This commit is contained in:
2026-07-25 10:49:53 +02:00
parent 214e12d2e2
commit 8928a4edb4
6 changed files with 156 additions and 16 deletions

View File

@@ -142,10 +142,19 @@ class GymRepository(
}
}
suspend fun completeSession(sessionId: Long, durationSecondsOverride: Int? = null) {
suspend fun completeSession(
sessionId: Long,
durationSecondsOverride: Int? = null,
editedStartEpochMs: Long? = null,
) {
val session = db.sessionDao().session(sessionId) ?: return
db.sessionDao().updateSession(
session.copy(status = "completed", durationSecondsOverride = durationSecondsOverride)
session.copy(
status = "completed",
durationSecondsOverride = durationSecondsOverride,
editedStartEpochMs = editedStartEpochMs,
startedAtEpochMs = editedStartEpochMs ?: session.startedAtEpochMs,
)
)
enqueue(OpKind.COMPLETE_SESSION, sessionId)
}

View File

@@ -183,6 +183,20 @@ class SyncEngine(
val session = dao.session(op.targetLocalId) ?: return
val sessionServerId = session.serverId
?: throw GraphQlException(listOf("Passet är inte synkat än"))
// Rättad starttid: flytta passets datum innan det avslutas.
session.editedStartEpochMs?.let { startMs ->
val iso = java.time.Instant.ofEpochMilli(startMs).toString()
client.execute(
"mutation(\$input: UpdateGymSessionInput!){updateGymSession(input:\$input){id date}}",
buildJsonObject {
put("input", buildJsonObject {
put("id", sessionServerId)
put("date", iso)
})
},
auth.bearerToken(),
)
}
val input = buildJsonObject {
put("id", sessionServerId)
session.name?.let { put("name", it) }

View File

@@ -18,7 +18,7 @@ import androidx.sqlite.db.SupportSQLiteDatabase
CachedMuscle::class,
CachedStartCard::class,
],
version = 2,
version = 3,
exportSchema = false,
)
abstract class AppDatabase : RoomDatabase() {
@@ -33,9 +33,15 @@ abstract class AppDatabase : RoomDatabase() {
}
}
private val MIGRATION_2_3 = object : Migration(2, 3) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE local_session ADD COLUMN editedStartEpochMs INTEGER")
}
}
fun build(context: Context): AppDatabase =
Room.databaseBuilder(context, AppDatabase::class.java, "fitnessdroid.db")
.addMigrations(MIGRATION_1_2)
.addMigrations(MIGRATION_1_2, MIGRATION_2_3)
.fallbackToDestructiveMigration()
.build()
}

View File

@@ -21,6 +21,8 @@ data class LocalSession(
val status: String = "active",
/** Manuellt rättad passtid — skickas som durationSecondsOverride vid avslut */
val durationSecondsOverride: Int? = null,
/** Manuellt rättad starttid — skickas som updateGymSession(date) vid avslut */
val editedStartEpochMs: Long? = null,
)
@Entity(

View File

@@ -195,10 +195,9 @@ fun SessionScreen(
CompleteSessionDialog(
uiState = uiState,
bodyWeightKg = bodyWeight,
elapsedSeconds = ((nowMs - uiState.startedAtEpochMs) / 1000).toInt().coerceAtLeast(60),
onConfirm = { durationSeconds ->
onConfirm = { durationSeconds, editedStartEpochMs ->
confirmComplete = false
viewModel.completeSession(durationSeconds, onSessionEnded)
viewModel.completeSession(durationSeconds, editedStartEpochMs, onSessionEnded)
},
onDismiss = { confirmComplete = false },
)
@@ -452,17 +451,83 @@ private fun Stepper(label: String, value: String, onMinus: () -> Unit, onPlus: (
}
}
/** Avsluta-överblicken: vad du gjort, redigerbar passtid och kcal med förklaring. */
/** Avsluta-överblicken: vad du gjort, redigerbar start + varaktighet och kcal. */
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun CompleteSessionDialog(
uiState: SessionUiState,
bodyWeightKg: Double?,
elapsedSeconds: Int,
onConfirm: (Int) -> Unit,
onConfirm: (durationSeconds: Int, editedStartEpochMs: Long?) -> Unit,
onDismiss: () -> Unit,
) {
var durationSeconds by remember { mutableStateOf(elapsedSeconds) }
val originalStart = uiState.startedAtEpochMs
var startEpochMs by remember { mutableStateOf(originalStart) }
// Startade du passet mitt i träningen? Sista loggade setet är ett bättre
// slut-ankare än "nu" om passet glömdes öppet — börja med spannet
// start → senaste aktivitet, minst 1 minut.
val lastActivity = remember {
uiState.pages.flatMap { it.doneSets }.maxOfOrNull { it.loggedAtEpochMs }
?: System.currentTimeMillis()
}
var durationSeconds by remember {
mutableStateOf(((lastActivity - originalStart) / 1000).toInt().coerceAtLeast(60))
}
var showCalorieInfo by remember { mutableStateOf(false) }
var showDatePicker by remember { mutableStateOf(false) }
var showTimePicker by remember { mutableStateOf(false) }
if (showDatePicker) {
val dateState = androidx.compose.material3.rememberDatePickerState(
initialSelectedDateMillis = startEpochMs,
)
androidx.compose.material3.DatePickerDialog(
onDismissRequest = { showDatePicker = false },
confirmButton = {
TextButton(onClick = {
dateState.selectedDateMillis?.let { picked ->
val zone = java.time.ZoneId.systemDefault()
val newDate = java.time.Instant.ofEpochMilli(picked)
.atZone(java.time.ZoneOffset.UTC).toLocalDate()
val time = java.time.Instant.ofEpochMilli(startEpochMs)
.atZone(zone).toLocalTime()
startEpochMs = newDate.atTime(time).atZone(zone).toInstant().toEpochMilli()
}
showDatePicker = false
}) { Text("OK") }
},
dismissButton = {
TextButton(onClick = { showDatePicker = false }) { Text("Avbryt") }
},
) {
androidx.compose.material3.DatePicker(state = dateState)
}
}
if (showTimePicker) {
val zone = java.time.ZoneId.systemDefault()
val current = java.time.Instant.ofEpochMilli(startEpochMs).atZone(zone)
val timeState = androidx.compose.material3.rememberTimePickerState(
initialHour = current.hour,
initialMinute = current.minute,
is24Hour = true,
)
AlertDialog(
onDismissRequest = { showTimePicker = false },
title = { Text("Starttid") },
text = { androidx.compose.material3.TimePicker(state = timeState) },
confirmButton = {
TextButton(onClick = {
startEpochMs = current.toLocalDate()
.atTime(timeState.hour, timeState.minute)
.atZone(zone).toInstant().toEpochMilli()
showTimePicker = false
}) { Text("OK") }
},
dismissButton = {
TextButton(onClick = { showTimePicker = false }) { Text("Avbryt") }
},
)
}
val totalSets = uiState.pages.sumOf { it.doneSets.size }
val totalReps = uiState.pages.sumOf { p -> p.doneSets.sumOf { it.reps ?: 0 } }
@@ -504,9 +569,37 @@ private fun CompleteSessionDialog(
style = MaterialTheme.typography.bodyMedium,
)
Column {
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text(
"PASSTID (justera om du glömde avsluta)",
"START (rätta om passet startades sent eller glömdes)",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
val zone = java.time.ZoneId.systemDefault()
val start = java.time.Instant.ofEpochMilli(startEpochMs).atZone(zone)
FilledTonalButton(
onClick = { showDatePicker = true },
modifier = Modifier.weight(1f),
) {
Text(
start.toLocalDate().format(
java.time.format.DateTimeFormatter.ofPattern("EEE d MMM")
)
)
}
FilledTonalButton(
onClick = { showTimePicker = true },
modifier = Modifier.weight(1f),
) {
Text("%02d:%02d".format(start.hour, start.minute))
}
}
}
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text(
"VARAKTIGHET",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
@@ -530,6 +623,13 @@ private fun CompleteSessionDialog(
FilledTonalButton(onClick = { durationSeconds += 60 }) { Text("+1 m") }
FilledTonalButton(onClick = { durationSeconds += 300 }) { Text("+5 m") }
}
Text(
"Slut: " + java.time.Instant.ofEpochMilli(startEpochMs + durationSeconds * 1000L)
.atZone(java.time.ZoneId.systemDefault())
.format(java.time.format.DateTimeFormatter.ofPattern("EEE d MMM HH:mm")),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Row(verticalAlignment = Alignment.CenterVertically) {
@@ -552,7 +652,12 @@ private fun CompleteSessionDialog(
}
},
confirmButton = {
Button(onClick = { onConfirm(durationSeconds) }) { Text("Avsluta pass") }
Button(onClick = {
onConfirm(
durationSeconds,
startEpochMs.takeIf { it != originalStart },
)
}) { Text("Avsluta pass") }
},
dismissButton = {
TextButton(onClick = onDismiss) { Text("Fortsätt träna") }

View File

@@ -212,11 +212,15 @@ class SessionViewModel(
viewModelScope.launch { bodyWeightKg.value = repo.bodyWeightKg() }
}
fun completeSession(durationSecondsOverride: Int?, onDone: () -> Unit) {
fun completeSession(
durationSecondsOverride: Int?,
editedStartEpochMs: Long?,
onDone: () -> Unit,
) {
viewModelScope.launch {
val id = uiState.value.sessionId ?: sessionFlow.first()?.id ?: return@launch
restTimer.dismiss()
repo.completeSession(id, durationSecondsOverride)
repo.completeSession(id, durationSecondsOverride, editedStartEpochMs)
onDone()
}
}