fix(android): complete active equipment workflow
This commit is contained in:
parent
e320767516
commit
47ee128807
5 changed files with 102 additions and 32 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in a new issue