diff --git a/CHANGELOG.md b/CHANGELOG.md index 4654204..27d6054 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,11 @@ ## Unreleased — 0.1.4 +- Fixed the Android active-session exercise editor for target-only continuous + preparation entries. Opening an occurrence now renders its prefilled form, + preserves its independent plan while editing performed values, updates only + the selected stable occurrence, and reports persistence failures instead of + displaying a false editor-opened confirmation. - Fixed Android activation of mixed session preparations containing continuous duration targets and set-based exercises. Continuous target metadata now remains planning-only, SQL `NULL` set counts round-trip correctly, and no diff --git a/android/app/src/main/java/com/labfytools/trainlog/data/TrainlogRepository.kt b/android/app/src/main/java/com/labfytools/trainlog/data/TrainlogRepository.kt index 818e80d..67df5da 100644 --- a/android/app/src/main/java/com/labfytools/trainlog/data/TrainlogRepository.kt +++ b/android/app/src/main/java/com/labfytools/trainlog/data/TrainlogRepository.kt @@ -8506,11 +8506,14 @@ class TrainlogRepository( draft.exercise.recordingMode ) { RecordingMode.CONTINUOUS -> { - if ( - draft.continuousDurationSeconds <= 0 || - draft.sets.isNotEmpty() || draft.plan != null - ) { + val hasPerformedActivity = draft.continuousDurationSeconds > 0 + val targetOnly = allowTargetOnly && !hasPerformedActivity && draft.plan != null + if ((!hasPerformedActivity && !targetOnly) || draft.sets.isNotEmpty()) { false + } else if (targetOnly) { + /* CONTRACT: activating a continuous target creates planning + * metadata only; supplemental performed facts remain absent. */ + draft.speedKmh == null && draft.distanceKm == null } else { val wantsSpeed = draft.exercise.dataFields and @@ -8572,10 +8575,17 @@ class TrainlogRepository( private fun validateSessionPlan(draft: SessionExerciseDraft): Boolean { val plan = draft.plan ?: return true - if (draft.exercise.recordingMode != RecordingMode.SETS || - plan.sets !in 1..MAX_PLAN_SETS || - plan.restSeconds !in 0..MAX_PLAN_REST_SECONDS || + if (plan.restSeconds !in 0..MAX_PLAN_REST_SECONDS || (plan.weightKg != null && (!plan.weightKg.isFinite() || plan.weightKg <= 0.0))) return false + if (draft.exercise.recordingMode == RecordingMode.CONTINUOUS) { + /* INVARIANT: the model's zero set count denotes SQL NULL for a + * continuous duration target; it never denotes a performed set. */ + return plan.sets == 0 && plan.reps == null && + plan.durationSeconds in 1..MAX_PLAN_DURATION_SECONDS && + plan.weightKg == null && plan.loadMode == SessionLoadMode.NONE && + plan.restSeconds == 0 + } + if (plan.sets !in 1..MAX_PLAN_SETS) return false val metricValid = when (draft.exercise.trackingMode) { TrackingMode.REPS -> plan.reps in 1..MAX_PLAN_REPS && plan.durationSeconds == null TrackingMode.DURATION -> plan.durationSeconds in 1..MAX_PLAN_DURATION_SECONDS && plan.reps == null diff --git a/android/app/src/main/java/com/labfytools/trainlog/ui/SessionScreen.kt b/android/app/src/main/java/com/labfytools/trainlog/ui/SessionScreen.kt index 56abb4b..8d31a50 100644 --- a/android/app/src/main/java/com/labfytools/trainlog/ui/SessionScreen.kt +++ b/android/app/src/main/java/com/labfytools/trainlog/ui/SessionScreen.kt @@ -342,14 +342,14 @@ fun SessionScreen( label = strings.getString(R.string.modify_name, draft.exercise.name), description = strings.getString(R.string.edit_exercise_description), accent = colors.accent, + modifier = Modifier.testTag("edit-draft-exercise-${draft.entryId}"), onClick = { persistDraft( currentDraft.copy( form = formForExistingExercise(draft, index), ), - null, + strings.getString(R.string.editor_opened, draft.exercise.name), ) - message = strings.getString(R.string.editor_opened, draft.exercise.name) }, ) if (currentDraft.form.editingEntryId == draft.entryId) { @@ -1265,6 +1265,7 @@ private fun SessionExerciseForm( SessionNumberField( label = strings.getString(R.string.duration_minutes), + testTag = "session-continuous-duration", value = durationText, onValueChange = { @@ -1396,7 +1397,9 @@ private fun SessionExerciseForm( onFailure = { error = localizedTargetError(it.message, strings) }, ) } else { - onAdd(draft.copy(plan = null)) + /* CONTRACT: editing performed continuous facts must not + * erase the independent target copied from preparation. */ + onAdd(draft.copy(plan = initialPlan)) } }, ) @@ -1408,6 +1411,7 @@ private fun SessionExerciseForm( strings.getString(R.string.return_catalog), accent = colors.muted, + modifier = Modifier.testTag("cancel-session-exercise-editor"), onClick = onCancel, ) diff --git a/android/app/src/test/java/com/labfytools/trainlog/ui/AiSessionDraftUiWiringTest.kt b/android/app/src/test/java/com/labfytools/trainlog/ui/AiSessionDraftUiWiringTest.kt index feb6869..110ff76 100644 --- a/android/app/src/test/java/com/labfytools/trainlog/ui/AiSessionDraftUiWiringTest.kt +++ b/android/app/src/test/java/com/labfytools/trainlog/ui/AiSessionDraftUiWiringTest.kt @@ -12,8 +12,9 @@ import androidx.compose.ui.test.junit4.createComposeRule import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.onNodeWithContentDescription import androidx.compose.ui.test.onNodeWithTag -import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performTextClearance +import androidx.compose.ui.test.performTextInput import androidx.compose.ui.test.performSemanticsAction import androidx.compose.ui.semantics.SemanticsActions import androidx.compose.ui.input.key.Key @@ -28,8 +29,12 @@ import com.labfytools.trainlog.data.ActiveDraftMutationResult import com.labfytools.trainlog.data.AiSessionDraftImportResult import com.labfytools.trainlog.data.CreateExerciseResult import com.labfytools.trainlog.data.TrainlogRepository +import com.labfytools.trainlog.model.ActiveSessionDraft import com.labfytools.trainlog.model.NewExerciseProfile import com.labfytools.trainlog.model.RecordingMode +import com.labfytools.trainlog.model.SessionExerciseDraft +import com.labfytools.trainlog.model.SessionExercisePlan +import com.labfytools.trainlog.model.SessionLoadMode import com.labfytools.trainlog.model.TrackingMode import com.labfytools.trainlog.ui.theme.TrainlogTheme import java.util.UUID @@ -130,6 +135,84 @@ class AiSessionDraftUiWiringTest { compose.runOnIdle { assertEquals(1, createCalls) } } + @Test + fun modifyingTargetOnlyOccurrenceRendersItsPrefilledEditor() { + val walk = createExercise("Marche ciblée", RecordingMode.CONTINUOUS, TrackingMode.DURATION) + val press = createExercise("Presse ciblée", RecordingMode.SETS, TrackingMode.REPS) + val walkEntry = SessionExerciseDraft( + entryId = "spe_11111111-1111-4111-8111-111111111111", + exercise = walk, + plan = SessionExercisePlan(sets = 0, durationSeconds = 600), + ) + val pressEntry = SessionExerciseDraft( + entryId = "spe_22222222-2222-4222-8222-222222222222", + exercise = press, + plan = SessionExercisePlan( + sets = 3, + reps = 10, + weightKg = 52.0, + loadMode = SessionLoadMode.EXTERNAL, + restSeconds = 90, + ), + ) + assertEquals( + ActiveDraftMutationResult.Saved, + repository.saveActiveSessionDraft( + ActiveSessionDraft(exercises = listOf(walkEntry, pressEntry)), + ), + ) + val loaded = (repository.loadActiveSessionDraft() as ActiveDraftLoadResult.Loaded).draft + assertEquals( + ActiveDraftMutationResult.Saved, + repository.saveActiveSessionDraft( + loaded.copy(form = formForExistingExercise(loaded.exercises[0], 0)), + ), + ) + assertEquals( + ActiveDraftMutationResult.Saved, + repository.saveActiveSessionDraft(loaded), + ) + compose.setContent { + TrainlogTheme { SessionScreen(repository, 0, {}, {}, {}) } + } + + compose.onNodeWithTag("edit-draft-exercise-${walkEntry.entryId}") + .performSemanticsAction(SemanticsActions.OnClick) + + compose.runOnIdle { + val active = repository.loadActiveSessionDraft() as ActiveDraftLoadResult.Loaded + assertEquals(walk.exerciseId, active.draft.form.selectedExercise?.exerciseId) + } + compose.onNodeWithText("SAISIE — Marche ciblée").assertExists() + compose.onNodeWithTag("session-continuous-duration").assertExists() + compose.onNodeWithText("Ajouter à la séance").assertExists() + + compose.onNodeWithTag("session-continuous-duration").performTextClearance() + compose.onNodeWithTag("session-continuous-duration").performTextInput("12") + compose.onNodeWithTag("add-to-session") + .performSemanticsAction(SemanticsActions.OnClick) + compose.runOnIdle { + val active = repository.loadActiveSessionDraft() as ActiveDraftLoadResult.Loaded + assertEquals(2, active.draft.exercises.size) + assertEquals(walkEntry.entryId, active.draft.exercises[0].entryId) + assertEquals(720, active.draft.exercises[0].continuousDurationSeconds) + assertEquals(600, active.draft.exercises[0].plan?.durationSeconds) + assertEquals(pressEntry, active.draft.exercises[1]) + } + + compose.onNodeWithTag("edit-draft-exercise-${pressEntry.entryId}") + .performSemanticsAction(SemanticsActions.OnClick) + compose.onNodeWithText("SAISIE — Presse ciblée").assertExists() + compose.onNodeWithTag("cancel-session-exercise-editor") + .performSemanticsAction(SemanticsActions.OnClick) + compose.runOnIdle { + val active = repository.loadActiveSessionDraft() as ActiveDraftLoadResult.Loaded + assertEquals(720, active.draft.exercises[0].continuousDurationSeconds) + assertEquals(pressEntry, active.draft.exercises[1]) + assertEquals(null, active.draft.form.selectedExercise) + } + } + @Test fun draftScreenShowsDosBicepsAndTargetDetails() { importDraft() @@ -299,6 +382,15 @@ class AiSessionDraftUiWiringTest { return payload } + private fun createExercise( + name: String, + recordingMode: RecordingMode, + trackingMode: TrackingMode, + ) = + (repository.createExercise( + NewExerciseProfile(name, recordingMode, trackingMode, 0), + ) as CreateExerciseResult.Created).exercise + private fun artifact(exerciseId: String): String { val target = JSONObject().put("sets", 3).put("reps", 10) .put("duration_seconds", JSONObject.NULL).put("weight_kg", JSONObject.NULL) diff --git a/docs/current_state.md b/docs/current_state.md index 5f5697e..a4dcdb5 100644 --- a/docs/current_state.md +++ b/docs/current_state.md @@ -49,6 +49,10 @@ Starting a delivered mixed preparation preserves continuous duration targets and set/repetition/load/rest targets in the active singleton without creating performed continuous activity, performed sets, MAX results, history, or feedback before explicit user capture. +The active-session editor accepts those target-only continuous occurrences: +opening an existing stable entry renders its prefilled performed-value form, +keeps the independent target intact on save, and leaves sibling occurrences +unchanged on save or cancellation. The opt-in full-generation path is operational on the private daily installation. Android explicit synchronization uses a dedicated coordination