diff --git a/CHANGELOG.md b/CHANGELOG.md index 8abc0f4..1653c87 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,10 +3,11 @@ ## Unreleased — 0.1.4 - Fixed equipment creation from the Android active-session exercise editor. - The editor now reuses the catalogue creation route, returns to the same - stable occurrence with all draft fields intact, selects the explicitly - created equipment, and saves the edit in place rather than appending a - duplicate occurrence. + The selector now exposes the complete catalogue in a bounded scrollable + list, reuses the catalogue creation route, returns to the same stable + occurrence with all draft fields intact, and selects the explicitly created + equipment. Target-only prepared occurrences can save that equipment in + place without fabricating performed sets or appending a duplicate occurrence. - 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 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 a43074a..94e0a81 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 @@ -25,6 +25,8 @@ import androidx.compose.foundation.layout.IntrinsicSize import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.text.BasicText import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material3.MaterialTheme @@ -450,8 +452,8 @@ fun SessionScreen( sessionType = currentDraft.sessionType, initialForm = currentDraft.form, - initialPlan = currentDraft.form.editingExerciseIndex?.let { - currentDraft.exercises.getOrNull(it)?.plan + initialOccurrence = currentDraft.form.editingExerciseIndex?.let { + currentDraft.exercises.getOrNull(it) }, onCreateEquipment = { onCreateEquipment(currentDraft.form.editingEntryId) @@ -880,7 +882,7 @@ private fun SessionExerciseForm( exercise: ExerciseProfile, sessionType: SessionType, initialForm: SessionDraftForm, - initialPlan: SessionExercisePlan?, + initialOccurrence: SessionExerciseDraft?, onCreateEquipment: () -> Unit, onFormChanged: (SessionDraftForm) -> Unit, onCancel: () -> Unit, @@ -891,6 +893,7 @@ private fun SessionExerciseForm( val colors = LocalTrainlogColors.current val strings = localizedContext() + val initialPlan = initialOccurrence?.plan var setCountText by remember(key) { @@ -1028,16 +1031,29 @@ private fun SessionExerciseForm( }, ) } - if (fixedEquipmentId == null) repository.searchEquipment(equipmentSearch).take(8).forEach { equipment -> - TrainlogAction( - label = if (equipment.equipmentId == selectedEquipmentId) "✓ ${equipment.displayName}" else equipment.displayName, - description = equipment.labelName.ifBlank { equipment.type }, - accent = if (equipment.equipmentId == selectedEquipmentId) colors.success else colors.muted, - onClick = { - selectedEquipmentId = equipment.equipmentId - onFormChanged(currentForm(exercise, setCountText, repsText, durationText, speedText, distanceText, equipment.equipmentId, weightText, maxWeightText)) - }, - ) + if (fixedEquipmentId == null) { + /* WHY: alphabetical truncation made valid equipment beyond the + * eighth row impossible to select. CONTRACT: the bounded nested + * viewport exposes every search result while the surrounding + * exercise editor remains independently scrollable. */ + Column( + Modifier + .heightIn(max = 320.dp) + .verticalScroll(rememberScrollState()) + .testTag("session-equipment-results"), + ) { + repository.searchEquipment(equipmentSearch).forEach { equipment -> + TrainlogAction( + label = if (equipment.equipmentId == selectedEquipmentId) "✓ ${equipment.displayName}" else equipment.displayName, + description = equipment.labelName.ifBlank { equipment.type }, + accent = if (equipment.equipmentId == selectedEquipmentId) colors.success else colors.muted, + onClick = { + selectedEquipmentId = equipment.equipmentId + onFormChanged(currentForm(exercise, setCountText, repsText, durationText, speedText, distanceText, equipment.equipmentId, weightText, maxWeightText)) + }, + ) + } + } } if (sessionType == SessionType.MAX_TEST) { @@ -1355,7 +1371,7 @@ private fun SessionExerciseForm( modifier = Modifier.fillMaxWidth().testTag("add-to-session"), containerColor = colors.success, onClick = { - val draft = + val capturedDraft = buildSessionExerciseDraft( exercise = exercise, @@ -1377,6 +1393,26 @@ private fun SessionExerciseForm( rawSetRows = setRows, ) + /* WHY: a prepared occurrence may legitimately have a target + * but no performed sets yet. CONTRACT: changing its equipment + * edits planning metadata in place without fabricating actual + * work. INVARIANT: any nonblank partial performed row still + * follows normal validation and can never be discarded here. */ + val targetOnlyDraft = initialOccurrence?.takeIf { + sessionType == SessionType.TRAINING && + it.plan != null && + it.sets.isEmpty() && + it.maxWeightKg == null && + it.continuousDurationSeconds == 0 && + setRows.all { row -> + row.repsText.isBlank() && row.weightText.isBlank() + } + }?.copy( + exercise = exercise, + equipmentId = selectedEquipmentId, + ) + val draft = capturedDraft ?: targetOnlyDraft + if (draft == null) { error = if (sessionType == SessionType.MAX_TEST) { diff --git a/android/app/src/test/java/com/labfytools/trainlog/ui/TrainlogAppNavigationCallbackTest.kt b/android/app/src/test/java/com/labfytools/trainlog/ui/TrainlogAppNavigationCallbackTest.kt index 1134af4..6b75fdc 100644 --- a/android/app/src/test/java/com/labfytools/trainlog/ui/TrainlogAppNavigationCallbackTest.kt +++ b/android/app/src/test/java/com/labfytools/trainlog/ui/TrainlogAppNavigationCallbackTest.kt @@ -15,6 +15,7 @@ import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.performTextInput import androidx.compose.ui.test.performSemanticsAction import androidx.compose.ui.test.performScrollTo +import androidx.compose.ui.test.performScrollToNode import androidx.compose.ui.semantics.SemanticsActions import androidx.test.core.app.ApplicationProvider import com.labfytools.trainlog.data.ActiveDraftMutationResult @@ -133,18 +134,31 @@ class TrainlogAppNavigationCallbackTest { ) as CreateExerciseResult.Created).exercise val occurrence = SessionExerciseDraft( exercise = exercise, - sets = listOf(SessionSetDraft(reps = 10, weightKg = 8.0)), plan = SessionExercisePlan( - sets = 1, + sets = 3, reps = 10, weightKg = 8.0, loadMode = SessionLoadMode.EXTERNAL, + restSeconds = 90, ), ) + val before = SessionExerciseDraft( + exercise = (repository.createExercise( + NewExerciseProfile("Marche témoin", RecordingMode.CONTINUOUS, TrackingMode.DURATION, 0), + ) as CreateExerciseResult.Created).exercise, + continuousDurationSeconds = 600, + ) + val after = SessionExerciseDraft( + exercise = (repository.createExercise( + NewExerciseProfile("Presse témoin", RecordingMode.SETS, TrackingMode.REPS, 0), + ) as CreateExerciseResult.Created).exercise, + sets = listOf(SessionSetDraft(reps = 12)), + ) + repository.createCustomEquipment("ZZZ équipement témoin") assertEquals( ActiveDraftMutationResult.Saved, repository.saveActiveSessionDraft( - ActiveSessionDraft(exercises = listOf(occurrence)), + ActiveSessionDraft(exercises = listOf(before, occurrence, after)), ), ) val editorState = TrainlogAppState().also { @@ -167,6 +181,8 @@ class TrainlogAppNavigationCallbackTest { compose.onNodeWithText("SAISIE — Goblet squat haltère") .performScrollTo() .assertIsDisplayed() + compose.onNodeWithTag("session-equipment-results") + .performScrollToNode(hasText("ZZZ équipement témoin")) compose.onNodeWithTag("session-create-equipment") .performSemanticsAction(SemanticsActions.OnClick) compose.waitForIdle() @@ -182,18 +198,26 @@ class TrainlogAppNavigationCallbackTest { compose.onNodeWithText("SAISIE — Goblet squat haltère") .performScrollTo() .assertIsDisplayed() - compose.onNodeWithText("✓ Haltère").performScrollTo().assertIsDisplayed() + compose.onNode( + hasText("✓ Haltère") and hasText("Équipement sélectionné."), + ).performScrollTo().assertIsDisplayed() compose.onNodeWithTag("add-to-session") .performSemanticsAction(SemanticsActions.OnClick) compose.runOnIdle { val loaded = repository.loadActiveSessionDraft() as ActiveDraftLoadResult.Loaded - assertEquals(1, loaded.draft.exercises.size) - assertEquals(occurrence.entryId, loaded.draft.exercises.single().entryId) - assertEquals(8.0, loaded.draft.exercises.single().plan?.weightKg) + assertEquals(3, loaded.draft.exercises.size) + assertEquals(before, loaded.draft.exercises[0]) + assertEquals(occurrence.entryId, loaded.draft.exercises[1].entryId) + assertTrue(loaded.draft.exercises[1].sets.isEmpty()) + assertEquals(3, loaded.draft.exercises[1].plan?.sets) + assertEquals(10, loaded.draft.exercises[1].plan?.reps) + assertEquals(90, loaded.draft.exercises[1].plan?.restSeconds) + assertEquals(8.0, loaded.draft.exercises[1].plan?.weightKg) + assertEquals(after, loaded.draft.exercises[2]) assertEquals( "Haltère", repository.listEquipment().single { - it.equipmentId == loaded.draft.exercises.single().equipmentId + it.equipmentId == loaded.draft.exercises[1].equipmentId }.displayName, ) } diff --git a/docs/android.md b/docs/android.md index d7f1c0d..8e1b8ad 100644 --- a/docs/android.md +++ b/docs/android.md @@ -889,6 +889,13 @@ route. An explicit successful creation returns automatically to the same exercise editor, restores the stable occurrence being edited, and selects the new persistent `eq_…` ID; the draft's other raw fields and target remain unchanged. No equipment is synthesized merely by opening the route. +The selector presents every matching catalogue entry inside a bounded, +independently scrollable result list; it does not truncate the selectable +catalogue to its first rows. An existing target-only prepared occurrence may +therefore change or create its equipment association and save in place while +retaining its `entry_id`, plan and sibling occurrences. This operation does not +fabricate a performed set; a partially entered performed row still follows the +normal validation path and is never silently discarded. The shared bundled catalogue uses reserved canonical IDs. User-created IDs are exchanged first by definitions V1, so a V2 association can resolve them on the receiving side; conflicts remain explicit and are never converted to null. diff --git a/docs/current_state.md b/docs/current_state.md index 0977a1d..f8ce1ae 100644 --- a/docs/current_state.md +++ b/docs/current_state.md @@ -49,10 +49,11 @@ 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, +The active-session editor accepts target-only continuous and set-based +occurrences: opening an existing stable entry renders its prefilled form, keeps the independent target intact on save, and leaves sibling occurrences -unchanged on save or cancellation. +unchanged on save or cancellation. A set-based target-only occurrence can add +or change equipment without fabricating a performed set. The opt-in full-generation path is operational on the private daily installation. Android explicit synchronization uses a dedicated coordination @@ -237,9 +238,10 @@ Android currently provides: - exactly one durable active session draft with identity-preserving continuous multi-position drag reorder, resume, confirmed discard, and atomic completion; -- caller-aware equipment creation from an active occurrence editor, with the - explicit catalogue-created ID selected on return and the occurrence identity - retained for in-place save; +- complete scrollable equipment selection and caller-aware creation from an + active occurrence editor, with the explicit catalogue-created ID selected on + return and the occurrence identity, plan, raw form and siblings retained for + in-place save; - completed-session consultation, transient drag reorder, and atomic factual or order correction while retaining session/occurrence/feedback identities for synchronization;