fix(android): complete active equipment workflow

This commit is contained in:
fy59 2026-09-21 15:15:16 +02:00
parent e320767516
commit 47ee128807
5 changed files with 102 additions and 32 deletions

View file

@ -3,10 +3,11 @@
## Unreleased — 0.1.4 ## Unreleased — 0.1.4
- Fixed equipment creation from the Android active-session exercise editor. - Fixed equipment creation from the Android active-session exercise editor.
The editor now reuses the catalogue creation route, returns to the same The selector now exposes the complete catalogue in a bounded scrollable
stable occurrence with all draft fields intact, selects the explicitly list, reuses the catalogue creation route, returns to the same stable
created equipment, and saves the edit in place rather than appending a occurrence with all draft fields intact, and selects the explicitly created
duplicate occurrence. 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 - Fixed the Android active-session exercise editor for target-only continuous
preparation entries. Opening an occurrence now renders its prefilled form, preparation entries. Opening an occurrence now renders its prefilled form,
preserves its independent plan while editing performed values, updates only preserves its independent plan while editing performed values, updates only

View file

@ -25,6 +25,8 @@ import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items 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.BasicText
import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
@ -450,8 +452,8 @@ fun SessionScreen(
sessionType = currentDraft.sessionType, sessionType = currentDraft.sessionType,
initialForm = initialForm =
currentDraft.form, currentDraft.form,
initialPlan = currentDraft.form.editingExerciseIndex?.let { initialOccurrence = currentDraft.form.editingExerciseIndex?.let {
currentDraft.exercises.getOrNull(it)?.plan currentDraft.exercises.getOrNull(it)
}, },
onCreateEquipment = { onCreateEquipment = {
onCreateEquipment(currentDraft.form.editingEntryId) onCreateEquipment(currentDraft.form.editingEntryId)
@ -880,7 +882,7 @@ private fun SessionExerciseForm(
exercise: ExerciseProfile, exercise: ExerciseProfile,
sessionType: SessionType, sessionType: SessionType,
initialForm: SessionDraftForm, initialForm: SessionDraftForm,
initialPlan: SessionExercisePlan?, initialOccurrence: SessionExerciseDraft?,
onCreateEquipment: () -> Unit, onCreateEquipment: () -> Unit,
onFormChanged: (SessionDraftForm) -> Unit, onFormChanged: (SessionDraftForm) -> Unit,
onCancel: () -> Unit, onCancel: () -> Unit,
@ -891,6 +893,7 @@ private fun SessionExerciseForm(
val colors = val colors =
LocalTrainlogColors.current LocalTrainlogColors.current
val strings = localizedContext() val strings = localizedContext()
val initialPlan = initialOccurrence?.plan
var setCountText by var setCountText by
remember(key) { remember(key) {
@ -1028,16 +1031,29 @@ private fun SessionExerciseForm(
}, },
) )
} }
if (fixedEquipmentId == null) repository.searchEquipment(equipmentSearch).take(8).forEach { equipment -> if (fixedEquipmentId == null) {
TrainlogAction( /* WHY: alphabetical truncation made valid equipment beyond the
label = if (equipment.equipmentId == selectedEquipmentId) "✓ ${equipment.displayName}" else equipment.displayName, * eighth row impossible to select. CONTRACT: the bounded nested
description = equipment.labelName.ifBlank { equipment.type }, * viewport exposes every search result while the surrounding
accent = if (equipment.equipmentId == selectedEquipmentId) colors.success else colors.muted, * exercise editor remains independently scrollable. */
onClick = { Column(
selectedEquipmentId = equipment.equipmentId Modifier
onFormChanged(currentForm(exercise, setCountText, repsText, durationText, speedText, distanceText, equipment.equipmentId, weightText, maxWeightText)) .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) { if (sessionType == SessionType.MAX_TEST) {
@ -1355,7 +1371,7 @@ private fun SessionExerciseForm(
modifier = Modifier.fillMaxWidth().testTag("add-to-session"), modifier = Modifier.fillMaxWidth().testTag("add-to-session"),
containerColor = colors.success, containerColor = colors.success,
onClick = { onClick = {
val draft = val capturedDraft =
buildSessionExerciseDraft( buildSessionExerciseDraft(
exercise = exercise =
exercise, exercise,
@ -1377,6 +1393,26 @@ private fun SessionExerciseForm(
rawSetRows = setRows, 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) { if (draft == null) {
error = error =
if (sessionType == SessionType.MAX_TEST) { if (sessionType == SessionType.MAX_TEST) {

View file

@ -15,6 +15,7 @@ import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.performTextInput import androidx.compose.ui.test.performTextInput
import androidx.compose.ui.test.performSemanticsAction import androidx.compose.ui.test.performSemanticsAction
import androidx.compose.ui.test.performScrollTo import androidx.compose.ui.test.performScrollTo
import androidx.compose.ui.test.performScrollToNode
import androidx.compose.ui.semantics.SemanticsActions import androidx.compose.ui.semantics.SemanticsActions
import androidx.test.core.app.ApplicationProvider import androidx.test.core.app.ApplicationProvider
import com.labfytools.trainlog.data.ActiveDraftMutationResult import com.labfytools.trainlog.data.ActiveDraftMutationResult
@ -133,18 +134,31 @@ class TrainlogAppNavigationCallbackTest {
) as CreateExerciseResult.Created).exercise ) as CreateExerciseResult.Created).exercise
val occurrence = SessionExerciseDraft( val occurrence = SessionExerciseDraft(
exercise = exercise, exercise = exercise,
sets = listOf(SessionSetDraft(reps = 10, weightKg = 8.0)),
plan = SessionExercisePlan( plan = SessionExercisePlan(
sets = 1, sets = 3,
reps = 10, reps = 10,
weightKg = 8.0, weightKg = 8.0,
loadMode = SessionLoadMode.EXTERNAL, 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( assertEquals(
ActiveDraftMutationResult.Saved, ActiveDraftMutationResult.Saved,
repository.saveActiveSessionDraft( repository.saveActiveSessionDraft(
ActiveSessionDraft(exercises = listOf(occurrence)), ActiveSessionDraft(exercises = listOf(before, occurrence, after)),
), ),
) )
val editorState = TrainlogAppState().also { val editorState = TrainlogAppState().also {
@ -167,6 +181,8 @@ class TrainlogAppNavigationCallbackTest {
compose.onNodeWithText("SAISIE — Goblet squat haltère") compose.onNodeWithText("SAISIE — Goblet squat haltère")
.performScrollTo() .performScrollTo()
.assertIsDisplayed() .assertIsDisplayed()
compose.onNodeWithTag("session-equipment-results")
.performScrollToNode(hasText("ZZZ équipement témoin"))
compose.onNodeWithTag("session-create-equipment") compose.onNodeWithTag("session-create-equipment")
.performSemanticsAction(SemanticsActions.OnClick) .performSemanticsAction(SemanticsActions.OnClick)
compose.waitForIdle() compose.waitForIdle()
@ -182,18 +198,26 @@ class TrainlogAppNavigationCallbackTest {
compose.onNodeWithText("SAISIE — Goblet squat haltère") compose.onNodeWithText("SAISIE — Goblet squat haltère")
.performScrollTo() .performScrollTo()
.assertIsDisplayed() .assertIsDisplayed()
compose.onNodeWithText("✓ Haltère").performScrollTo().assertIsDisplayed() compose.onNode(
hasText("✓ Haltère") and hasText("Équipement sélectionné."),
).performScrollTo().assertIsDisplayed()
compose.onNodeWithTag("add-to-session") compose.onNodeWithTag("add-to-session")
.performSemanticsAction(SemanticsActions.OnClick) .performSemanticsAction(SemanticsActions.OnClick)
compose.runOnIdle { compose.runOnIdle {
val loaded = repository.loadActiveSessionDraft() as ActiveDraftLoadResult.Loaded val loaded = repository.loadActiveSessionDraft() as ActiveDraftLoadResult.Loaded
assertEquals(1, loaded.draft.exercises.size) assertEquals(3, loaded.draft.exercises.size)
assertEquals(occurrence.entryId, loaded.draft.exercises.single().entryId) assertEquals(before, loaded.draft.exercises[0])
assertEquals(8.0, loaded.draft.exercises.single().plan?.weightKg) 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( assertEquals(
"Haltère", "Haltère",
repository.listEquipment().single { repository.listEquipment().single {
it.equipmentId == loaded.draft.exercises.single().equipmentId it.equipmentId == loaded.draft.exercises[1].equipmentId
}.displayName, }.displayName,
) )
} }

View file

@ -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 exercise editor, restores the stable occurrence being edited, and selects the
new persistent `eq_…` ID; the draft's other raw fields and target remain new persistent `eq_…` ID; the draft's other raw fields and target remain
unchanged. No equipment is synthesized merely by opening the route. 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 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 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. receiving side; conflicts remain explicit and are never converted to null.

View file

@ -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 and set/repetition/load/rest targets in the active singleton without creating
performed continuous activity, performed sets, MAX results, history, or performed continuous activity, performed sets, MAX results, history, or
feedback before explicit user capture. feedback before explicit user capture.
The active-session editor accepts those target-only continuous occurrences: The active-session editor accepts target-only continuous and set-based
opening an existing stable entry renders its prefilled performed-value form, occurrences: opening an existing stable entry renders its prefilled form,
keeps the independent target intact on save, and leaves sibling occurrences 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 The opt-in full-generation path is operational on the private daily
installation. Android explicit synchronization uses a dedicated coordination 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 - exactly one durable active session draft with identity-preserving continuous
multi-position drag reorder, multi-position drag reorder,
resume, confirmed discard, and atomic completion; resume, confirmed discard, and atomic completion;
- caller-aware equipment creation from an active occurrence editor, with the - complete scrollable equipment selection and caller-aware creation from an
explicit catalogue-created ID selected on return and the occurrence identity active occurrence editor, with the explicit catalogue-created ID selected on
retained for in-place save; 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 - completed-session consultation, transient drag reorder, and atomic factual or
order correction while retaining session/occurrence/feedback identities for order correction while retaining session/occurrence/feedback identities for
synchronization; synchronization;