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
|
## 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
|
- Fixed Android activation of mixed session preparations containing continuous
|
||||||
duration targets and set-based exercises. Continuous target metadata now
|
duration targets and set-based exercises. Continuous target metadata now
|
||||||
remains planning-only, SQL `NULL` set counts round-trip correctly, and no
|
remains planning-only, SQL `NULL` set counts round-trip correctly, and no
|
||||||
|
|
|
||||||
|
|
@ -8506,11 +8506,14 @@ class TrainlogRepository(
|
||||||
draft.exercise.recordingMode
|
draft.exercise.recordingMode
|
||||||
) {
|
) {
|
||||||
RecordingMode.CONTINUOUS -> {
|
RecordingMode.CONTINUOUS -> {
|
||||||
if (
|
val hasPerformedActivity = draft.continuousDurationSeconds > 0
|
||||||
draft.continuousDurationSeconds <= 0 ||
|
val targetOnly = allowTargetOnly && !hasPerformedActivity && draft.plan != null
|
||||||
draft.sets.isNotEmpty() || draft.plan != null
|
if ((!hasPerformedActivity && !targetOnly) || draft.sets.isNotEmpty()) {
|
||||||
) {
|
|
||||||
false
|
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 {
|
} else {
|
||||||
val wantsSpeed =
|
val wantsSpeed =
|
||||||
draft.exercise.dataFields and
|
draft.exercise.dataFields and
|
||||||
|
|
@ -8572,10 +8575,17 @@ class TrainlogRepository(
|
||||||
|
|
||||||
private fun validateSessionPlan(draft: SessionExerciseDraft): Boolean {
|
private fun validateSessionPlan(draft: SessionExerciseDraft): Boolean {
|
||||||
val plan = draft.plan ?: return true
|
val plan = draft.plan ?: return true
|
||||||
if (draft.exercise.recordingMode != RecordingMode.SETS ||
|
if (plan.restSeconds !in 0..MAX_PLAN_REST_SECONDS ||
|
||||||
plan.sets !in 1..MAX_PLAN_SETS ||
|
|
||||||
plan.restSeconds !in 0..MAX_PLAN_REST_SECONDS ||
|
|
||||||
(plan.weightKg != null && (!plan.weightKg.isFinite() || plan.weightKg <= 0.0))) return false
|
(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) {
|
val metricValid = when (draft.exercise.trackingMode) {
|
||||||
TrackingMode.REPS -> plan.reps in 1..MAX_PLAN_REPS && plan.durationSeconds == null
|
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
|
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),
|
label = strings.getString(R.string.modify_name, draft.exercise.name),
|
||||||
description = strings.getString(R.string.edit_exercise_description),
|
description = strings.getString(R.string.edit_exercise_description),
|
||||||
accent = colors.accent,
|
accent = colors.accent,
|
||||||
|
modifier = Modifier.testTag("edit-draft-exercise-${draft.entryId}"),
|
||||||
onClick = {
|
onClick = {
|
||||||
persistDraft(
|
persistDraft(
|
||||||
currentDraft.copy(
|
currentDraft.copy(
|
||||||
form = formForExistingExercise(draft, index),
|
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) {
|
if (currentDraft.form.editingEntryId == draft.entryId) {
|
||||||
|
|
@ -1265,6 +1265,7 @@ private fun SessionExerciseForm(
|
||||||
SessionNumberField(
|
SessionNumberField(
|
||||||
label =
|
label =
|
||||||
strings.getString(R.string.duration_minutes),
|
strings.getString(R.string.duration_minutes),
|
||||||
|
testTag = "session-continuous-duration",
|
||||||
value =
|
value =
|
||||||
durationText,
|
durationText,
|
||||||
onValueChange = {
|
onValueChange = {
|
||||||
|
|
@ -1396,7 +1397,9 @@ private fun SessionExerciseForm(
|
||||||
onFailure = { error = localizedTargetError(it.message, strings) },
|
onFailure = { error = localizedTargetError(it.message, strings) },
|
||||||
)
|
)
|
||||||
} else {
|
} 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),
|
strings.getString(R.string.return_catalog),
|
||||||
accent =
|
accent =
|
||||||
colors.muted,
|
colors.muted,
|
||||||
|
modifier = Modifier.testTag("cancel-session-exercise-editor"),
|
||||||
onClick =
|
onClick =
|
||||||
onCancel,
|
onCancel,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -12,8 +12,9 @@ import androidx.compose.ui.test.junit4.createComposeRule
|
||||||
import androidx.compose.ui.test.onNodeWithText
|
import androidx.compose.ui.test.onNodeWithText
|
||||||
import androidx.compose.ui.test.onNodeWithContentDescription
|
import androidx.compose.ui.test.onNodeWithContentDescription
|
||||||
import androidx.compose.ui.test.onNodeWithTag
|
import androidx.compose.ui.test.onNodeWithTag
|
||||||
import androidx.compose.ui.test.onNodeWithTag
|
|
||||||
import androidx.compose.ui.test.performClick
|
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.test.performSemanticsAction
|
||||||
import androidx.compose.ui.semantics.SemanticsActions
|
import androidx.compose.ui.semantics.SemanticsActions
|
||||||
import androidx.compose.ui.input.key.Key
|
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.AiSessionDraftImportResult
|
||||||
import com.labfytools.trainlog.data.CreateExerciseResult
|
import com.labfytools.trainlog.data.CreateExerciseResult
|
||||||
import com.labfytools.trainlog.data.TrainlogRepository
|
import com.labfytools.trainlog.data.TrainlogRepository
|
||||||
|
import com.labfytools.trainlog.model.ActiveSessionDraft
|
||||||
import com.labfytools.trainlog.model.NewExerciseProfile
|
import com.labfytools.trainlog.model.NewExerciseProfile
|
||||||
import com.labfytools.trainlog.model.RecordingMode
|
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.model.TrackingMode
|
||||||
import com.labfytools.trainlog.ui.theme.TrainlogTheme
|
import com.labfytools.trainlog.ui.theme.TrainlogTheme
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
|
@ -130,6 +135,84 @@ class AiSessionDraftUiWiringTest {
|
||||||
compose.runOnIdle { assertEquals(1, createCalls) }
|
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
|
@Test
|
||||||
fun draftScreenShowsDosBicepsAndTargetDetails() {
|
fun draftScreenShowsDosBicepsAndTargetDetails() {
|
||||||
importDraft()
|
importDraft()
|
||||||
|
|
@ -299,6 +382,15 @@ class AiSessionDraftUiWiringTest {
|
||||||
return payload
|
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 {
|
private fun artifact(exerciseId: String): String {
|
||||||
val target = JSONObject().put("sets", 3).put("reps", 10)
|
val target = JSONObject().put("sets", 3).put("reps", 10)
|
||||||
.put("duration_seconds", JSONObject.NULL).put("weight_kg", JSONObject.NULL)
|
.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
|
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:
|
||||||
|
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
|
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
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue