From 8928a4edb412c6a0fc7d33d9e1b800e4a76362a5 Mon Sep 17 00:00:00 2001 From: claude Date: Sat, 25 Jul 2026 10:49:53 +0200 Subject: [PATCH] Avsluta-dialogen: redigerbar start (datum + klockslag) och varaktighet MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 Claude-Session: https://claude.ai/code/session_01Kf4MAbZ3c3B4Xy5XfuGsCP --- .../fitnessdroid/data/GymRepository.kt | 13 +- .../brassepc/fitnessdroid/data/SyncEngine.kt | 14 ++ .../fitnessdroid/data/local/AppDatabase.kt | 10 +- .../fitnessdroid/data/local/Entities.kt | 2 + .../fitnessdroid/ui/session/SessionScreen.kt | 125 ++++++++++++++++-- .../ui/session/SessionViewModel.kt | 8 +- 6 files changed, 156 insertions(+), 16 deletions(-) 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 a29d4cb..5a7302a 100644 --- a/app/src/main/java/eu/brassepc/fitnessdroid/data/GymRepository.kt +++ b/app/src/main/java/eu/brassepc/fitnessdroid/data/GymRepository.kt @@ -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) } 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 73dd325..3449d99 100644 --- a/app/src/main/java/eu/brassepc/fitnessdroid/data/SyncEngine.kt +++ b/app/src/main/java/eu/brassepc/fitnessdroid/data/SyncEngine.kt @@ -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) } 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 3b61e35..facaf14 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 @@ -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() } 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 64eeea4..47faa83 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 @@ -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( 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 7d44a4e..5772c2d 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 @@ -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") } 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 4588de7..37610df 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 @@ -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() } }