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
- 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

View file

@ -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) {

View file

@ -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,
)
}

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
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.

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
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;