Avsluta-dialogen: redigerbar start (datum + klockslag) och varaktighet
All checks were successful
release / build-release (push) Successful in 4m58s
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:
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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) }
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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") }
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user