fix(android): render active exercise editor

This commit is contained in:
fy59 2026-09-21 13:35:05 +02:00
parent e2284be1e3
commit c5e62c2e5e
5 changed files with 126 additions and 11 deletions

View file

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

View file

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

View file

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

View file

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

View file

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