fix(android): render active exercise editor
This commit is contained in:
parent
e2284be1e3
commit
c5e62c2e5e
5 changed files with 126 additions and 11 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue