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
|
val session = db.sessionDao().session(sessionId) ?: return
|
||||||
db.sessionDao().updateSession(
|
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)
|
enqueue(OpKind.COMPLETE_SESSION, sessionId)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -183,6 +183,20 @@ class SyncEngine(
|
|||||||
val session = dao.session(op.targetLocalId) ?: return
|
val session = dao.session(op.targetLocalId) ?: return
|
||||||
val sessionServerId = session.serverId
|
val sessionServerId = session.serverId
|
||||||
?: throw GraphQlException(listOf("Passet är inte synkat än"))
|
?: 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 {
|
val input = buildJsonObject {
|
||||||
put("id", sessionServerId)
|
put("id", sessionServerId)
|
||||||
session.name?.let { put("name", it) }
|
session.name?.let { put("name", it) }
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import androidx.sqlite.db.SupportSQLiteDatabase
|
|||||||
CachedMuscle::class,
|
CachedMuscle::class,
|
||||||
CachedStartCard::class,
|
CachedStartCard::class,
|
||||||
],
|
],
|
||||||
version = 2,
|
version = 3,
|
||||||
exportSchema = false,
|
exportSchema = false,
|
||||||
)
|
)
|
||||||
abstract class AppDatabase : RoomDatabase() {
|
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 =
|
fun build(context: Context): AppDatabase =
|
||||||
Room.databaseBuilder(context, AppDatabase::class.java, "fitnessdroid.db")
|
Room.databaseBuilder(context, AppDatabase::class.java, "fitnessdroid.db")
|
||||||
.addMigrations(MIGRATION_1_2)
|
.addMigrations(MIGRATION_1_2, MIGRATION_2_3)
|
||||||
.fallbackToDestructiveMigration()
|
.fallbackToDestructiveMigration()
|
||||||
.build()
|
.build()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ data class LocalSession(
|
|||||||
val status: String = "active",
|
val status: String = "active",
|
||||||
/** Manuellt rättad passtid — skickas som durationSecondsOverride vid avslut */
|
/** Manuellt rättad passtid — skickas som durationSecondsOverride vid avslut */
|
||||||
val durationSecondsOverride: Int? = null,
|
val durationSecondsOverride: Int? = null,
|
||||||
|
/** Manuellt rättad starttid — skickas som updateGymSession(date) vid avslut */
|
||||||
|
val editedStartEpochMs: Long? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
@Entity(
|
@Entity(
|
||||||
|
|||||||
@@ -195,10 +195,9 @@ fun SessionScreen(
|
|||||||
CompleteSessionDialog(
|
CompleteSessionDialog(
|
||||||
uiState = uiState,
|
uiState = uiState,
|
||||||
bodyWeightKg = bodyWeight,
|
bodyWeightKg = bodyWeight,
|
||||||
elapsedSeconds = ((nowMs - uiState.startedAtEpochMs) / 1000).toInt().coerceAtLeast(60),
|
onConfirm = { durationSeconds, editedStartEpochMs ->
|
||||||
onConfirm = { durationSeconds ->
|
|
||||||
confirmComplete = false
|
confirmComplete = false
|
||||||
viewModel.completeSession(durationSeconds, onSessionEnded)
|
viewModel.completeSession(durationSeconds, editedStartEpochMs, onSessionEnded)
|
||||||
},
|
},
|
||||||
onDismiss = { confirmComplete = false },
|
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
|
@Composable
|
||||||
private fun CompleteSessionDialog(
|
private fun CompleteSessionDialog(
|
||||||
uiState: SessionUiState,
|
uiState: SessionUiState,
|
||||||
bodyWeightKg: Double?,
|
bodyWeightKg: Double?,
|
||||||
elapsedSeconds: Int,
|
onConfirm: (durationSeconds: Int, editedStartEpochMs: Long?) -> Unit,
|
||||||
onConfirm: (Int) -> Unit,
|
|
||||||
onDismiss: () -> 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 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 totalSets = uiState.pages.sumOf { it.doneSets.size }
|
||||||
val totalReps = uiState.pages.sumOf { p -> p.doneSets.sumOf { it.reps ?: 0 } }
|
val totalReps = uiState.pages.sumOf { p -> p.doneSets.sumOf { it.reps ?: 0 } }
|
||||||
@@ -504,9 +569,37 @@ private fun CompleteSessionDialog(
|
|||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
)
|
)
|
||||||
|
|
||||||
Column {
|
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||||
Text(
|
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,
|
style = MaterialTheme.typography.labelSmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
@@ -530,6 +623,13 @@ private fun CompleteSessionDialog(
|
|||||||
FilledTonalButton(onClick = { durationSeconds += 60 }) { Text("+1 m") }
|
FilledTonalButton(onClick = { durationSeconds += 60 }) { Text("+1 m") }
|
||||||
FilledTonalButton(onClick = { durationSeconds += 300 }) { Text("+5 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) {
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
@@ -552,7 +652,12 @@ private fun CompleteSessionDialog(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
confirmButton = {
|
confirmButton = {
|
||||||
Button(onClick = { onConfirm(durationSeconds) }) { Text("Avsluta pass") }
|
Button(onClick = {
|
||||||
|
onConfirm(
|
||||||
|
durationSeconds,
|
||||||
|
startEpochMs.takeIf { it != originalStart },
|
||||||
|
)
|
||||||
|
}) { Text("Avsluta pass") }
|
||||||
},
|
},
|
||||||
dismissButton = {
|
dismissButton = {
|
||||||
TextButton(onClick = onDismiss) { Text("Fortsätt träna") }
|
TextButton(onClick = onDismiss) { Text("Fortsätt träna") }
|
||||||
|
|||||||
@@ -212,11 +212,15 @@ class SessionViewModel(
|
|||||||
viewModelScope.launch { bodyWeightKg.value = repo.bodyWeightKg() }
|
viewModelScope.launch { bodyWeightKg.value = repo.bodyWeightKg() }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun completeSession(durationSecondsOverride: Int?, onDone: () -> Unit) {
|
fun completeSession(
|
||||||
|
durationSecondsOverride: Int?,
|
||||||
|
editedStartEpochMs: Long?,
|
||||||
|
onDone: () -> Unit,
|
||||||
|
) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
val id = uiState.value.sessionId ?: sessionFlow.first()?.id ?: return@launch
|
val id = uiState.value.sessionId ?: sessionFlow.first()?.id ?: return@launch
|
||||||
restTimer.dismiss()
|
restTimer.dismiss()
|
||||||
repo.completeSession(id, durationSecondsOverride)
|
repo.completeSession(id, durationSecondsOverride, editedStartEpochMs)
|
||||||
onDone()
|
onDone()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user