fix(android): create equipment from active editor
This commit is contained in:
parent
c5e62c2e5e
commit
e320767516
13 changed files with 215 additions and 40 deletions
|
|
@ -2,6 +2,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.
|
||||
- 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
|
||||
|
|
|
|||
|
|
@ -60,6 +60,7 @@ class DraftUiTestActivity : ComponentActivity() {
|
|||
sessionVisible = false
|
||||
},
|
||||
onCreateExercise = {},
|
||||
onCreateEquipment = { _ -> },
|
||||
/* CONTRACT: this host never writes shared export data. */
|
||||
onSessionSaved = {},
|
||||
)
|
||||
|
|
|
|||
|
|
@ -171,5 +171,11 @@ class TrainlogAppState : ViewModel() {
|
|||
val equipment = EquipmentScreenState()
|
||||
val exercise = ExerciseScreenState()
|
||||
val body = BodyScreenState()
|
||||
/* WHY: the durable form intentionally does not serialize transient edit
|
||||
* position, but a catalogue detour must still return to the same row.
|
||||
* CONTRACT: this stable occurrence ID spans only that caller-aware detour;
|
||||
* all actual form fields remain repository-owned and durable.
|
||||
* INVARIANT: null means a new occurrence, never an inferred row. */
|
||||
var pendingSessionEquipmentEntryId: String? = null
|
||||
val navigationController = AppNavigationController(navigation, generator, equipment, exercise, body)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -423,17 +423,29 @@ fun EquipmentDetailScreen(repository: TrainlogRepository, equipmentId: String, o
|
|||
}
|
||||
|
||||
@Composable
|
||||
fun EquipmentCreateScreen(repository: TrainlogRepository, state: EquipmentScreenState, onCreated: () -> Unit) {
|
||||
fun EquipmentCreateScreen(
|
||||
repository: TrainlogRepository,
|
||||
state: EquipmentScreenState,
|
||||
onCreated: (EquipmentCatalogEntry) -> Unit,
|
||||
) {
|
||||
val colors = LocalTrainlogColors.current
|
||||
val strings = localizedContext()
|
||||
TrainlogScreen(strings.getString(R.string.create_equipment), scrollKey = "equipment-create") {
|
||||
TrainlogFrame(strings.getString(R.string.personal_equipment)) {
|
||||
TrainlogInputField(strings.getString(R.string.name), state.customName, { state.customName = it; state.message = null })
|
||||
TrainlogInputField(
|
||||
strings.getString(R.string.name),
|
||||
state.customName,
|
||||
{ state.customName = it; state.message = null },
|
||||
testTag = "equipment-create-name",
|
||||
)
|
||||
TrainlogPrimaryAction(strings.getString(R.string.create), strings.getString(R.string.create_machine_description)) {
|
||||
when (val result = repository.createCustomEquipment(state.customName)) {
|
||||
is CreateEquipmentResult.Created -> {
|
||||
state.selectedEquipmentId = result.equipment.equipmentId
|
||||
state.customName = ""; state.message = null; state.revision++; onCreated()
|
||||
state.customName = ""
|
||||
state.message = null
|
||||
state.revision++
|
||||
onCreated(result.equipment)
|
||||
}
|
||||
CreateEquipmentResult.Invalid -> state.message = strings.getString(R.string.equipment_name_invalid)
|
||||
CreateEquipmentResult.Conflict -> state.message = strings.getString(R.string.equipment_name_exists)
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ import androidx.compose.foundation.text.BasicText
|
|||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.key
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
|
|
@ -48,7 +49,6 @@ import androidx.compose.ui.text.input.KeyboardType
|
|||
import androidx.compose.ui.unit.dp
|
||||
import com.labfytools.trainlog.data.ActiveDraftLoadResult
|
||||
import com.labfytools.trainlog.data.ActiveDraftMutationResult
|
||||
import com.labfytools.trainlog.data.CreateEquipmentResult
|
||||
import com.labfytools.trainlog.data.EquipmentLoadSemantics
|
||||
import com.labfytools.trainlog.data.FinalizeActiveDraftResult
|
||||
import com.labfytools.trainlog.data.ManualPercentMaxResult
|
||||
|
|
@ -77,7 +77,10 @@ fun SessionScreen(
|
|||
catalogRevision: Int,
|
||||
onBack: () -> Unit,
|
||||
onCreateExercise: () -> Unit,
|
||||
onCreateEquipment: (editingEntryId: String?) -> Unit,
|
||||
onSessionSaved: () -> Unit,
|
||||
restoreEditingEntryId: String? = null,
|
||||
onEditingContextRestored: () -> Unit = {},
|
||||
) {
|
||||
val colors =
|
||||
LocalTrainlogColors.current
|
||||
|
|
@ -97,13 +100,27 @@ fun SessionScreen(
|
|||
|
||||
var activeDraft by
|
||||
remember(catalogRevision) {
|
||||
val loadedDraft = (initialLoad as? ActiveDraftLoadResult.Loaded)?.draft
|
||||
val restoredIndex = restoreEditingEntryId?.let { entryId ->
|
||||
loadedDraft?.exercises?.indexOfFirst { it.entryId == entryId }
|
||||
?.takeIf { it >= 0 }
|
||||
}
|
||||
mutableStateOf(
|
||||
(initialLoad as?
|
||||
ActiveDraftLoadResult.Loaded)
|
||||
?.draft
|
||||
if (restoredIndex == null) loadedDraft else loadedDraft?.let { draft ->
|
||||
draft.copy(
|
||||
form = draft.form.copy(
|
||||
editingExerciseIndex = restoredIndex,
|
||||
editingEntryId = restoreEditingEntryId,
|
||||
),
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
LaunchedEffect(restoreEditingEntryId) {
|
||||
if (restoreEditingEntryId != null) onEditingContextRestored()
|
||||
}
|
||||
|
||||
var message by
|
||||
remember(catalogRevision) {
|
||||
mutableStateOf<
|
||||
|
|
@ -436,6 +453,9 @@ fun SessionScreen(
|
|||
initialPlan = currentDraft.form.editingExerciseIndex?.let {
|
||||
currentDraft.exercises.getOrNull(it)?.plan
|
||||
},
|
||||
onCreateEquipment = {
|
||||
onCreateEquipment(currentDraft.form.editingEntryId)
|
||||
},
|
||||
onFormChanged = {
|
||||
form ->
|
||||
persistDraft(
|
||||
|
|
@ -861,6 +881,7 @@ private fun SessionExerciseForm(
|
|||
sessionType: SessionType,
|
||||
initialForm: SessionDraftForm,
|
||||
initialPlan: SessionExercisePlan?,
|
||||
onCreateEquipment: () -> Unit,
|
||||
onFormChanged: (SessionDraftForm) -> Unit,
|
||||
onCancel: () -> Unit,
|
||||
onAdd:
|
||||
|
|
@ -935,13 +956,11 @@ private fun SessionExerciseForm(
|
|||
)
|
||||
}
|
||||
|
||||
var equipmentRevision by remember(key) { mutableStateOf(0) }
|
||||
val equipmentEntries = remember(equipmentRevision) { repository.listEquipment() }
|
||||
val equipmentEntries = remember(key) { repository.listEquipment() }
|
||||
val fixedEquipmentId = remember(exercise.exerciseId) {
|
||||
repository.machineExerciseLegacyEquipmentId(exercise.exerciseId)
|
||||
}
|
||||
var equipmentSearch by remember(key) { mutableStateOf("") }
|
||||
var customEquipmentName by remember(key) { mutableStateOf("") }
|
||||
var selectedEquipmentId by remember(key) {
|
||||
mutableStateOf(initialForm.selectedEquipmentId ?: fixedEquipmentId)
|
||||
}
|
||||
|
|
@ -978,34 +997,18 @@ private fun SessionExerciseForm(
|
|||
value = equipmentSearch,
|
||||
onValueChange = { equipmentSearch = it },
|
||||
)
|
||||
TrainlogInputField(
|
||||
label = strings.getString(R.string.new_historical_context),
|
||||
value = customEquipmentName,
|
||||
onValueChange = { customEquipmentName = it },
|
||||
)
|
||||
TrainlogAction(
|
||||
label = strings.getString(R.string.create_context),
|
||||
description = strings.getString(R.string.context_compatibility),
|
||||
label = strings.getString(R.string.create_equipment),
|
||||
description = strings.getString(R.string.create_equipment_from_session),
|
||||
accent = colors.success,
|
||||
onClick = {
|
||||
when (val result = repository.createCustomEquipment(customEquipmentName)) {
|
||||
is CreateEquipmentResult.Created -> {
|
||||
customEquipmentName = ""
|
||||
equipmentRevision += 1
|
||||
selectedEquipmentId = result.equipment.equipmentId
|
||||
onFormChanged(currentForm(exercise, setCountText, repsText, durationText, speedText, distanceText, selectedEquipmentId, weightText, maxWeightText))
|
||||
}
|
||||
CreateEquipmentResult.Invalid -> error = strings.getString(R.string.context_name_invalid)
|
||||
CreateEquipmentResult.Conflict -> error = strings.getString(R.string.context_exists)
|
||||
is CreateEquipmentResult.DatabaseError -> error = strings.getString(R.string.context_create_error, localizedRepositoryMessage(strings, result.message))
|
||||
}
|
||||
},
|
||||
modifier = Modifier.testTag("session-create-equipment"),
|
||||
onClick = onCreateEquipment,
|
||||
)
|
||||
}
|
||||
val selectedEquipment = equipmentEntries.firstOrNull { it.equipmentId == selectedEquipmentId }
|
||||
val percentResult = remember(
|
||||
exercise.exerciseId, selectedEquipmentId, targetPercentText,
|
||||
targetChoice, equipmentRevision,
|
||||
targetChoice,
|
||||
) {
|
||||
if (targetChoice == ManualTargetChoice.PERCENT_MAX)
|
||||
repository.calculateManualPercentMaxTarget(
|
||||
|
|
|
|||
|
|
@ -105,7 +105,21 @@ fun TrainlogApp(repository: TrainlogRepository, exporter: SyncExporter, inbox: S
|
|||
)
|
||||
AppRoute.Sessions -> SessionsHub(activeDraft, pendingAiDraftCount, { open(AppRoute.SessionEditor) }, ::openManualSession,
|
||||
{ open(AppRoute.AiSessionDrafts) }, { open(AppRoute.CompletedSessions) }, { open(AppRoute.Programs) })
|
||||
AppRoute.SessionEditor -> SessionScreen(repository, catalogRevision, { draftRevision++; back() }, { open(AppRoute.ExerciseCreate(AppRoute.SessionEditor)) }, { exportSnapshot(); draftRevision++ })
|
||||
AppRoute.SessionEditor -> SessionScreen(
|
||||
repository,
|
||||
catalogRevision,
|
||||
{ draftRevision++; back() },
|
||||
{ open(AppRoute.ExerciseCreate(AppRoute.SessionEditor)) },
|
||||
{ editingEntryId ->
|
||||
appState.pendingSessionEquipmentEntryId = editingEntryId
|
||||
open(AppRoute.EquipmentCreate(AppRoute.SessionEditor))
|
||||
},
|
||||
{ exportSnapshot(); draftRevision++ },
|
||||
restoreEditingEntryId = appState.pendingSessionEquipmentEntryId,
|
||||
onEditingContextRestored = {
|
||||
appState.pendingSessionEquipmentEntryId = null
|
||||
},
|
||||
)
|
||||
AppRoute.SessionGenerator -> SessionGeneratorScreen(repository, generatorState, { back() }, { generatorState.abandon(); draftRevision++; open(AppRoute.SessionEditor) }, {
|
||||
draftMessage = strings.getString(R.string.existing_draft_warning)
|
||||
draftRevision++
|
||||
|
|
@ -138,7 +152,45 @@ fun TrainlogApp(repository: TrainlogRepository, exporter: SyncExporter, inbox: S
|
|||
is AppRoute.ExerciseEdit -> ExerciseEditorRoute(repository, exerciseState, route.exerciseId, false) { exportSnapshot(); catalogRevision++; back() }
|
||||
AppRoute.Equipment -> EquipmentScreen(repository, equipmentState, { open(AppRoute.EquipmentCreate(AppRoute.Equipment)) }, { open(AppRoute.EquipmentDetail(it)) })
|
||||
is AppRoute.EquipmentDetail -> EquipmentDetailScreen(repository, route.equipmentId) { open(AppRoute.ExerciseDetail(it)) }
|
||||
is AppRoute.EquipmentCreate -> EquipmentCreateScreen(repository, equipmentState) { exportSnapshot(); catalogRevision++; back() }
|
||||
is AppRoute.EquipmentCreate -> EquipmentCreateScreen(repository, equipmentState) { equipment ->
|
||||
/* WHY: equipment creation temporarily leaves the active-session editor.
|
||||
* CONTRACT: returning from that catalogue route preserves the durable
|
||||
* form and selects the exact stable ID that was just created.
|
||||
* INVARIANT: no equipment is synthesized; this runs only after the
|
||||
* explicit EquipmentCreateScreen action succeeded. */
|
||||
if (route.caller == AppRoute.SessionEditor) {
|
||||
when (val loaded = repository.loadActiveSessionDraft()) {
|
||||
is ActiveDraftLoadResult.Loaded -> when (
|
||||
val saved = repository.saveActiveSessionDraft(
|
||||
loaded.draft.copy(
|
||||
form = loaded.draft.form.copy(
|
||||
editingExerciseIndex = appState.pendingSessionEquipmentEntryId
|
||||
?.let { entryId ->
|
||||
loaded.draft.exercises.indexOfFirst {
|
||||
it.entryId == entryId
|
||||
}.takeIf { it >= 0 }
|
||||
},
|
||||
editingEntryId = appState.pendingSessionEquipmentEntryId,
|
||||
selectedEquipmentId = equipment.equipmentId,
|
||||
),
|
||||
),
|
||||
)
|
||||
) {
|
||||
ActiveDraftMutationResult.Saved -> draftRevision++
|
||||
is ActiveDraftMutationResult.Error -> {
|
||||
draftMessage = localizedRepositoryMessage(localized, saved.message)
|
||||
}
|
||||
}
|
||||
is ActiveDraftLoadResult.Error -> {
|
||||
draftMessage = localizedRepositoryMessage(localized, loaded.message)
|
||||
}
|
||||
ActiveDraftLoadResult.None -> Unit
|
||||
}
|
||||
}
|
||||
exportSnapshot()
|
||||
catalogRevision++
|
||||
back()
|
||||
}
|
||||
AppRoute.BodyMeasurements -> BodyScreen(repository, bodyState, { exportSnapshot() }, { back() })
|
||||
AppRoute.SleepDiary -> SleepDiaryScreen(repository)
|
||||
AppRoute.LatestMaxima -> LatestMaximaScreen(repository)
|
||||
|
|
|
|||
|
|
@ -417,7 +417,7 @@
|
|||
<string name="regenerate_description">Run again with the same inputs and reference instant.</string><string name="accept_actual_description">Create the normal draft without prefilling performed sets.</string><string name="acknowledge_exposure_first">Confirm the recent-exposure warning first.</string>
|
||||
<string name="reason_observed_dose">observed repeated dose</string><string name="reason_max_no_prescription">observed maximum without numeric prescription</string><string name="reason_user_max">user-selected MAX percentage</string><string name="reason_max_unavailable">compatible MAX unavailable</string><string name="reason_assistance_omitted">assistance load omitted</string><string name="reason_load_absent">numeric load absent</string><string name="reason_manual_load">manually entered load</string><string name="reason_preferred">preferred exercise</string><string name="reason_primary_zone">requested primary zone</string><string name="reason_secondary_zone">requested secondary zone</string><string name="reason_distinct_movement">distinct movement</string><string name="reason_recent_exercise">same exercise observed recently</string><string name="reason_recent_pattern">similar movement observed recently</string>
|
||||
<string name="shortage_upper">missing upper region</string><string name="shortage_lower">missing lower region</string><string name="shortage_core">missing core</string><string name="shortage_push">missing upper-body push</string><string name="shortage_pull">missing upper-body pull</string><string name="shortage_lower_extension">missing lower-body extension</string><string name="shortage_lower_flexion">missing lower-body flexion</string><string name="shortage_exercises">fewer distinct exercises available</string>
|
||||
<string name="create_equipment">Create equipment</string><string name="create_personal_machine">Add a personal machine.</string><string name="search_equipment">Search by name, label, or alias</string>
|
||||
<string name="create_equipment">Create equipment</string><string name="create_personal_machine">Add a personal machine.</string><string name="create_equipment_from_session">Create it in the catalogue, then return to this exercise.</string><string name="search_equipment">Search by name, label, or alias</string>
|
||||
<string name="unknown_reference">Unknown reference · %1$s</string><string name="definition">Definition</string><string name="personal">Personal</string><string name="provided">Provided</string><string name="label_value">Label: %1$s</string>
|
||||
<string name="confidence_value">%1$s confidence</string><string name="verified_sources">Verified sources: %1$d</string><string name="personal_equipment">Personal equipment</string><string name="create">Create</string>
|
||||
<string name="create_machine_description">Create a personal machine with the current semantics.</string><string name="equipment_name_invalid">Enter a name of 1 to 120 characters.</string><string name="equipment_name_exists">Equipment already has this name.</string><string name="creation_failed">Creation failed: %1$s.</string>
|
||||
|
|
|
|||
|
|
@ -417,7 +417,7 @@
|
|||
<string name="regenerate_description">Relancer les mêmes entrées et le même instant de référence.</string><string name="accept_actual_description">Créer le brouillon normal sans préremplir les séries réalisées.</string><string name="acknowledge_exposure_first">Confirmez d’abord l’avertissement d’exposition récente.</string>
|
||||
<string name="reason_observed_dose">dose répétée observée</string><string name="reason_max_no_prescription">maximum observé sans prescription numérique</string><string name="reason_user_max">pourcentage de MAX choisi par l’utilisateur</string><string name="reason_max_unavailable">MAX compatible indisponible</string><string name="reason_assistance_omitted">charge d’assistance omise</string><string name="reason_load_absent">charge numérique absente</string><string name="reason_manual_load">charge saisie manuellement</string><string name="reason_preferred">exercice préféré</string><string name="reason_primary_zone">zone principale demandée</string><string name="reason_secondary_zone">zone secondaire demandée</string><string name="reason_distinct_movement">mouvement distinct</string><string name="reason_recent_exercise">même exercice observé récemment</string><string name="reason_recent_pattern">mouvement similaire observé récemment</string>
|
||||
<string name="shortage_upper">région supérieure manquante</string><string name="shortage_lower">région inférieure manquante</string><string name="shortage_core">tronc manquant</string><string name="shortage_push">poussée du haut du corps manquante</string><string name="shortage_pull">tirage du haut du corps manquant</string><string name="shortage_lower_extension">extension du bas du corps manquante</string><string name="shortage_lower_flexion">flexion du bas du corps manquante</string><string name="shortage_exercises">moins d’exercices distincts disponibles</string>
|
||||
<string name="create_equipment">Créer un équipement</string><string name="create_personal_machine">Ajouter une machine personnelle.</string><string name="search_equipment">Rechercher par nom, étiquette ou alias</string>
|
||||
<string name="create_equipment">Créer un équipement</string><string name="create_personal_machine">Ajouter une machine personnelle.</string><string name="create_equipment_from_session">Créer dans le catalogue puis revenir à cet exercice.</string><string name="search_equipment">Rechercher par nom, étiquette ou alias</string>
|
||||
<string name="unknown_reference">Référence inconnue · %1$s</string><string name="definition">Définition</string><string name="personal">Personnel</string><string name="provided">Fourni</string><string name="label_value">Étiquette : %1$s</string>
|
||||
<string name="confidence_value">Confiance %1$s</string><string name="verified_sources">Sources vérifiées : %1$d</string><string name="personal_equipment">Équipement personnel</string><string name="create">Créer</string>
|
||||
<string name="create_machine_description">Créer une machine personnelle avec la sémantique actuelle.</string><string name="equipment_name_invalid">Saisissez un nom de 1 à 120 caractères.</string><string name="equipment_name_exists">Un équipement porte déjà ce nom.</string><string name="creation_failed">Création impossible : %1$s.</string>
|
||||
|
|
|
|||
|
|
@ -120,7 +120,7 @@ class AiSessionDraftUiWiringTest {
|
|||
assertEquals(ActiveDraftMutationResult.Saved, repository.startActiveSessionDraft())
|
||||
var createCalls = 0
|
||||
compose.setContent { TrainlogTheme {
|
||||
SessionScreen(repository, 0, {}, { createCalls++ }, {})
|
||||
SessionScreen(repository, 0, {}, { createCalls++ }, { _ -> }, {})
|
||||
} }
|
||||
|
||||
val create = compose.onNodeWithTag("active-session-create-exercise")
|
||||
|
|
@ -173,7 +173,7 @@ class AiSessionDraftUiWiringTest {
|
|||
repository.saveActiveSessionDraft(loaded),
|
||||
)
|
||||
compose.setContent {
|
||||
TrainlogTheme { SessionScreen(repository, 0, {}, {}, {}) }
|
||||
TrainlogTheme { SessionScreen(repository, 0, {}, {}, { _ -> }, {}) }
|
||||
}
|
||||
|
||||
compose.onNodeWithTag("edit-draft-exercise-${walkEntry.entryId}")
|
||||
|
|
|
|||
|
|
@ -67,7 +67,7 @@ class DraftFeedbackUiReadbackTest {
|
|||
val completedSessionId = mutableStateOf<String?>(null)
|
||||
compose.setContent { TrainlogTheme {
|
||||
completedSessionId.value?.let { SessionDetailScreen(repository, it, {}, {}) }
|
||||
?: SessionScreen(repository, 0, {}, {}, {})
|
||||
?: SessionScreen(repository, 0, {}, {}, { _ -> }, {})
|
||||
} }
|
||||
compose.onNodeWithTag("draft-feedback-${entry.entryId}").fetchSemanticsNode()
|
||||
compose.onNodeWithText("Ressenti · texte UI durable").fetchSemanticsNode()
|
||||
|
|
|
|||
|
|
@ -7,15 +7,21 @@ package com.labfytools.trainlog.ui
|
|||
|
||||
import android.content.Context
|
||||
import androidx.compose.ui.test.junit4.createComposeRule
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.hasClickAction
|
||||
import androidx.compose.ui.test.hasText
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
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.semantics.SemanticsActions
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import com.labfytools.trainlog.data.ActiveDraftMutationResult
|
||||
import com.labfytools.trainlog.data.ActiveDraftLoadResult
|
||||
import com.labfytools.trainlog.data.BodyZoneRecentExposure
|
||||
import com.labfytools.trainlog.data.ExposureWindowSummary
|
||||
import com.labfytools.trainlog.data.CreateExerciseResult
|
||||
import com.labfytools.trainlog.data.GenerationWarningLevel
|
||||
import com.labfytools.trainlog.data.SessionGenerationPreview
|
||||
import com.labfytools.trainlog.data.SessionGenerationPreviewExercise
|
||||
|
|
@ -26,6 +32,13 @@ import com.labfytools.trainlog.data.SyncExporter
|
|||
import com.labfytools.trainlog.data.SyncRequestOutbox
|
||||
import com.labfytools.trainlog.data.TrainlogRepository
|
||||
import com.labfytools.trainlog.model.SessionExercisePlan
|
||||
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.SessionLoadMode
|
||||
import com.labfytools.trainlog.model.SessionSetDraft
|
||||
import com.labfytools.trainlog.model.TrackingMode
|
||||
import com.labfytools.trainlog.ui.theme.TrainlogTheme
|
||||
import java.security.MessageDigest
|
||||
import java.util.UUID
|
||||
|
|
@ -109,6 +122,83 @@ class TrainlogAppNavigationCallbackTest {
|
|||
assertArrayEquals(before, databaseDigest())
|
||||
}
|
||||
|
||||
@Test fun activeExerciseEditorCreatesEquipmentReturnsSelectsAndSaves() {
|
||||
val exercise = (repository.createExercise(
|
||||
NewExerciseProfile(
|
||||
"Goblet squat haltère",
|
||||
RecordingMode.SETS,
|
||||
TrackingMode.REPS,
|
||||
0,
|
||||
),
|
||||
) as CreateExerciseResult.Created).exercise
|
||||
val occurrence = SessionExerciseDraft(
|
||||
exercise = exercise,
|
||||
sets = listOf(SessionSetDraft(reps = 10, weightKg = 8.0)),
|
||||
plan = SessionExercisePlan(
|
||||
sets = 1,
|
||||
reps = 10,
|
||||
weightKg = 8.0,
|
||||
loadMode = SessionLoadMode.EXTERNAL,
|
||||
),
|
||||
)
|
||||
assertEquals(
|
||||
ActiveDraftMutationResult.Saved,
|
||||
repository.saveActiveSessionDraft(
|
||||
ActiveSessionDraft(exercises = listOf(occurrence)),
|
||||
),
|
||||
)
|
||||
val editorState = TrainlogAppState().also {
|
||||
it.navigation.open(AppRoute.SessionEditor)
|
||||
}
|
||||
compose.setContent {
|
||||
TrainlogTheme {
|
||||
TrainlogApp(
|
||||
repository,
|
||||
SyncExporter(context, repository),
|
||||
SyncCatalogInbox(context, repository),
|
||||
SyncRequestOutbox(context),
|
||||
editorState,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
compose.onNodeWithTag("edit-draft-exercise-${occurrence.entryId}")
|
||||
.performSemanticsAction(SemanticsActions.OnClick)
|
||||
compose.onNodeWithText("SAISIE — Goblet squat haltère")
|
||||
.performScrollTo()
|
||||
.assertIsDisplayed()
|
||||
compose.onNodeWithTag("session-create-equipment")
|
||||
.performSemanticsAction(SemanticsActions.OnClick)
|
||||
compose.waitForIdle()
|
||||
assertEquals(AppRoute.EquipmentCreate(AppRoute.SessionEditor), editorState.navigation.route)
|
||||
assertEquals(occurrence.entryId, editorState.pendingSessionEquipmentEntryId)
|
||||
|
||||
compose.onNodeWithTag("equipment-create-name").performTextInput("Haltère")
|
||||
compose.onNodeWithText("Créer")
|
||||
.performSemanticsAction(SemanticsActions.OnClick)
|
||||
compose.waitForIdle()
|
||||
|
||||
assertEquals(AppRoute.SessionEditor, editorState.navigation.route)
|
||||
compose.onNodeWithText("SAISIE — Goblet squat haltère")
|
||||
.performScrollTo()
|
||||
.assertIsDisplayed()
|
||||
compose.onNodeWithText("✓ Haltère").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(
|
||||
"Haltère",
|
||||
repository.listEquipment().single {
|
||||
it.equipmentId == loaded.draft.exercises.single().equipmentId
|
||||
}.displayName,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun databaseDigest(): ByteArray = MessageDigest.getInstance("SHA-256")
|
||||
.digest(context.getDatabasePath(databaseName).readBytes())
|
||||
|
||||
|
|
|
|||
|
|
@ -884,8 +884,11 @@ decimal input is accepted (`12,5`); one value applies to all sets or values may
|
|||
be separated with `;`. Assisted equipment is explicitly labelled
|
||||
`Assistance (kg)`. Empty load remains distinct from an entered zero.
|
||||
|
||||
`Nouvelle machine` in that same selector creates a persistent local custom
|
||||
equipment entry with a generated stable `eq_…` ID and selects it immediately.
|
||||
`Créer un équipement` in that selector opens the existing catalogue creation
|
||||
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 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.
|
||||
|
|
|
|||
|
|
@ -237,6 +237,9 @@ 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;
|
||||
- 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