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
|
## 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
|
- Fixed the Android active-session exercise editor for target-only continuous
|
||||||
preparation entries. Opening an occurrence now renders its prefilled form,
|
preparation entries. Opening an occurrence now renders its prefilled form,
|
||||||
preserves its independent plan while editing performed values, updates only
|
preserves its independent plan while editing performed values, updates only
|
||||||
|
|
|
||||||
|
|
@ -60,6 +60,7 @@ class DraftUiTestActivity : ComponentActivity() {
|
||||||
sessionVisible = false
|
sessionVisible = false
|
||||||
},
|
},
|
||||||
onCreateExercise = {},
|
onCreateExercise = {},
|
||||||
|
onCreateEquipment = { _ -> },
|
||||||
/* CONTRACT: this host never writes shared export data. */
|
/* CONTRACT: this host never writes shared export data. */
|
||||||
onSessionSaved = {},
|
onSessionSaved = {},
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -171,5 +171,11 @@ class TrainlogAppState : ViewModel() {
|
||||||
val equipment = EquipmentScreenState()
|
val equipment = EquipmentScreenState()
|
||||||
val exercise = ExerciseScreenState()
|
val exercise = ExerciseScreenState()
|
||||||
val body = BodyScreenState()
|
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)
|
val navigationController = AppNavigationController(navigation, generator, equipment, exercise, body)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -423,17 +423,29 @@ fun EquipmentDetailScreen(repository: TrainlogRepository, equipmentId: String, o
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun EquipmentCreateScreen(repository: TrainlogRepository, state: EquipmentScreenState, onCreated: () -> Unit) {
|
fun EquipmentCreateScreen(
|
||||||
|
repository: TrainlogRepository,
|
||||||
|
state: EquipmentScreenState,
|
||||||
|
onCreated: (EquipmentCatalogEntry) -> Unit,
|
||||||
|
) {
|
||||||
val colors = LocalTrainlogColors.current
|
val colors = LocalTrainlogColors.current
|
||||||
val strings = localizedContext()
|
val strings = localizedContext()
|
||||||
TrainlogScreen(strings.getString(R.string.create_equipment), scrollKey = "equipment-create") {
|
TrainlogScreen(strings.getString(R.string.create_equipment), scrollKey = "equipment-create") {
|
||||||
TrainlogFrame(strings.getString(R.string.personal_equipment)) {
|
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)) {
|
TrainlogPrimaryAction(strings.getString(R.string.create), strings.getString(R.string.create_machine_description)) {
|
||||||
when (val result = repository.createCustomEquipment(state.customName)) {
|
when (val result = repository.createCustomEquipment(state.customName)) {
|
||||||
is CreateEquipmentResult.Created -> {
|
is CreateEquipmentResult.Created -> {
|
||||||
state.selectedEquipmentId = result.equipment.equipmentId
|
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.Invalid -> state.message = strings.getString(R.string.equipment_name_invalid)
|
||||||
CreateEquipmentResult.Conflict -> state.message = strings.getString(R.string.equipment_name_exists)
|
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.foundation.text.KeyboardOptions
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.key
|
import androidx.compose.runtime.key
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
|
@ -48,7 +49,6 @@ import androidx.compose.ui.text.input.KeyboardType
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import com.labfytools.trainlog.data.ActiveDraftLoadResult
|
import com.labfytools.trainlog.data.ActiveDraftLoadResult
|
||||||
import com.labfytools.trainlog.data.ActiveDraftMutationResult
|
import com.labfytools.trainlog.data.ActiveDraftMutationResult
|
||||||
import com.labfytools.trainlog.data.CreateEquipmentResult
|
|
||||||
import com.labfytools.trainlog.data.EquipmentLoadSemantics
|
import com.labfytools.trainlog.data.EquipmentLoadSemantics
|
||||||
import com.labfytools.trainlog.data.FinalizeActiveDraftResult
|
import com.labfytools.trainlog.data.FinalizeActiveDraftResult
|
||||||
import com.labfytools.trainlog.data.ManualPercentMaxResult
|
import com.labfytools.trainlog.data.ManualPercentMaxResult
|
||||||
|
|
@ -77,7 +77,10 @@ fun SessionScreen(
|
||||||
catalogRevision: Int,
|
catalogRevision: Int,
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
onCreateExercise: () -> Unit,
|
onCreateExercise: () -> Unit,
|
||||||
|
onCreateEquipment: (editingEntryId: String?) -> Unit,
|
||||||
onSessionSaved: () -> Unit,
|
onSessionSaved: () -> Unit,
|
||||||
|
restoreEditingEntryId: String? = null,
|
||||||
|
onEditingContextRestored: () -> Unit = {},
|
||||||
) {
|
) {
|
||||||
val colors =
|
val colors =
|
||||||
LocalTrainlogColors.current
|
LocalTrainlogColors.current
|
||||||
|
|
@ -97,12 +100,26 @@ fun SessionScreen(
|
||||||
|
|
||||||
var activeDraft by
|
var activeDraft by
|
||||||
remember(catalogRevision) {
|
remember(catalogRevision) {
|
||||||
|
val loadedDraft = (initialLoad as? ActiveDraftLoadResult.Loaded)?.draft
|
||||||
|
val restoredIndex = restoreEditingEntryId?.let { entryId ->
|
||||||
|
loadedDraft?.exercises?.indexOfFirst { it.entryId == entryId }
|
||||||
|
?.takeIf { it >= 0 }
|
||||||
|
}
|
||||||
mutableStateOf(
|
mutableStateOf(
|
||||||
(initialLoad as?
|
if (restoredIndex == null) loadedDraft else loadedDraft?.let { draft ->
|
||||||
ActiveDraftLoadResult.Loaded)
|
draft.copy(
|
||||||
?.draft
|
form = draft.form.copy(
|
||||||
|
editingExerciseIndex = restoredIndex,
|
||||||
|
editingEntryId = restoreEditingEntryId,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(restoreEditingEntryId) {
|
||||||
|
if (restoreEditingEntryId != null) onEditingContextRestored()
|
||||||
|
}
|
||||||
|
|
||||||
var message by
|
var message by
|
||||||
remember(catalogRevision) {
|
remember(catalogRevision) {
|
||||||
|
|
@ -436,6 +453,9 @@ fun SessionScreen(
|
||||||
initialPlan = currentDraft.form.editingExerciseIndex?.let {
|
initialPlan = currentDraft.form.editingExerciseIndex?.let {
|
||||||
currentDraft.exercises.getOrNull(it)?.plan
|
currentDraft.exercises.getOrNull(it)?.plan
|
||||||
},
|
},
|
||||||
|
onCreateEquipment = {
|
||||||
|
onCreateEquipment(currentDraft.form.editingEntryId)
|
||||||
|
},
|
||||||
onFormChanged = {
|
onFormChanged = {
|
||||||
form ->
|
form ->
|
||||||
persistDraft(
|
persistDraft(
|
||||||
|
|
@ -861,6 +881,7 @@ private fun SessionExerciseForm(
|
||||||
sessionType: SessionType,
|
sessionType: SessionType,
|
||||||
initialForm: SessionDraftForm,
|
initialForm: SessionDraftForm,
|
||||||
initialPlan: SessionExercisePlan?,
|
initialPlan: SessionExercisePlan?,
|
||||||
|
onCreateEquipment: () -> Unit,
|
||||||
onFormChanged: (SessionDraftForm) -> Unit,
|
onFormChanged: (SessionDraftForm) -> Unit,
|
||||||
onCancel: () -> Unit,
|
onCancel: () -> Unit,
|
||||||
onAdd:
|
onAdd:
|
||||||
|
|
@ -935,13 +956,11 @@ private fun SessionExerciseForm(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
var equipmentRevision by remember(key) { mutableStateOf(0) }
|
val equipmentEntries = remember(key) { repository.listEquipment() }
|
||||||
val equipmentEntries = remember(equipmentRevision) { repository.listEquipment() }
|
|
||||||
val fixedEquipmentId = remember(exercise.exerciseId) {
|
val fixedEquipmentId = remember(exercise.exerciseId) {
|
||||||
repository.machineExerciseLegacyEquipmentId(exercise.exerciseId)
|
repository.machineExerciseLegacyEquipmentId(exercise.exerciseId)
|
||||||
}
|
}
|
||||||
var equipmentSearch by remember(key) { mutableStateOf("") }
|
var equipmentSearch by remember(key) { mutableStateOf("") }
|
||||||
var customEquipmentName by remember(key) { mutableStateOf("") }
|
|
||||||
var selectedEquipmentId by remember(key) {
|
var selectedEquipmentId by remember(key) {
|
||||||
mutableStateOf(initialForm.selectedEquipmentId ?: fixedEquipmentId)
|
mutableStateOf(initialForm.selectedEquipmentId ?: fixedEquipmentId)
|
||||||
}
|
}
|
||||||
|
|
@ -978,34 +997,18 @@ private fun SessionExerciseForm(
|
||||||
value = equipmentSearch,
|
value = equipmentSearch,
|
||||||
onValueChange = { equipmentSearch = it },
|
onValueChange = { equipmentSearch = it },
|
||||||
)
|
)
|
||||||
TrainlogInputField(
|
|
||||||
label = strings.getString(R.string.new_historical_context),
|
|
||||||
value = customEquipmentName,
|
|
||||||
onValueChange = { customEquipmentName = it },
|
|
||||||
)
|
|
||||||
TrainlogAction(
|
TrainlogAction(
|
||||||
label = strings.getString(R.string.create_context),
|
label = strings.getString(R.string.create_equipment),
|
||||||
description = strings.getString(R.string.context_compatibility),
|
description = strings.getString(R.string.create_equipment_from_session),
|
||||||
accent = colors.success,
|
accent = colors.success,
|
||||||
onClick = {
|
modifier = Modifier.testTag("session-create-equipment"),
|
||||||
when (val result = repository.createCustomEquipment(customEquipmentName)) {
|
onClick = onCreateEquipment,
|
||||||
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))
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
val selectedEquipment = equipmentEntries.firstOrNull { it.equipmentId == selectedEquipmentId }
|
val selectedEquipment = equipmentEntries.firstOrNull { it.equipmentId == selectedEquipmentId }
|
||||||
val percentResult = remember(
|
val percentResult = remember(
|
||||||
exercise.exerciseId, selectedEquipmentId, targetPercentText,
|
exercise.exerciseId, selectedEquipmentId, targetPercentText,
|
||||||
targetChoice, equipmentRevision,
|
targetChoice,
|
||||||
) {
|
) {
|
||||||
if (targetChoice == ManualTargetChoice.PERCENT_MAX)
|
if (targetChoice == ManualTargetChoice.PERCENT_MAX)
|
||||||
repository.calculateManualPercentMaxTarget(
|
repository.calculateManualPercentMaxTarget(
|
||||||
|
|
|
||||||
|
|
@ -105,7 +105,21 @@ fun TrainlogApp(repository: TrainlogRepository, exporter: SyncExporter, inbox: S
|
||||||
)
|
)
|
||||||
AppRoute.Sessions -> SessionsHub(activeDraft, pendingAiDraftCount, { open(AppRoute.SessionEditor) }, ::openManualSession,
|
AppRoute.Sessions -> SessionsHub(activeDraft, pendingAiDraftCount, { open(AppRoute.SessionEditor) }, ::openManualSession,
|
||||||
{ open(AppRoute.AiSessionDrafts) }, { open(AppRoute.CompletedSessions) }, { open(AppRoute.Programs) })
|
{ 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) }, {
|
AppRoute.SessionGenerator -> SessionGeneratorScreen(repository, generatorState, { back() }, { generatorState.abandon(); draftRevision++; open(AppRoute.SessionEditor) }, {
|
||||||
draftMessage = strings.getString(R.string.existing_draft_warning)
|
draftMessage = strings.getString(R.string.existing_draft_warning)
|
||||||
draftRevision++
|
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() }
|
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)) })
|
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.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.BodyMeasurements -> BodyScreen(repository, bodyState, { exportSnapshot() }, { back() })
|
||||||
AppRoute.SleepDiary -> SleepDiaryScreen(repository)
|
AppRoute.SleepDiary -> SleepDiaryScreen(repository)
|
||||||
AppRoute.LatestMaxima -> LatestMaximaScreen(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="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="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="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="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="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>
|
<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="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="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="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="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="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>
|
<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())
|
assertEquals(ActiveDraftMutationResult.Saved, repository.startActiveSessionDraft())
|
||||||
var createCalls = 0
|
var createCalls = 0
|
||||||
compose.setContent { TrainlogTheme {
|
compose.setContent { TrainlogTheme {
|
||||||
SessionScreen(repository, 0, {}, { createCalls++ }, {})
|
SessionScreen(repository, 0, {}, { createCalls++ }, { _ -> }, {})
|
||||||
} }
|
} }
|
||||||
|
|
||||||
val create = compose.onNodeWithTag("active-session-create-exercise")
|
val create = compose.onNodeWithTag("active-session-create-exercise")
|
||||||
|
|
@ -173,7 +173,7 @@ class AiSessionDraftUiWiringTest {
|
||||||
repository.saveActiveSessionDraft(loaded),
|
repository.saveActiveSessionDraft(loaded),
|
||||||
)
|
)
|
||||||
compose.setContent {
|
compose.setContent {
|
||||||
TrainlogTheme { SessionScreen(repository, 0, {}, {}, {}) }
|
TrainlogTheme { SessionScreen(repository, 0, {}, {}, { _ -> }, {}) }
|
||||||
}
|
}
|
||||||
|
|
||||||
compose.onNodeWithTag("edit-draft-exercise-${walkEntry.entryId}")
|
compose.onNodeWithTag("edit-draft-exercise-${walkEntry.entryId}")
|
||||||
|
|
|
||||||
|
|
@ -67,7 +67,7 @@ class DraftFeedbackUiReadbackTest {
|
||||||
val completedSessionId = mutableStateOf<String?>(null)
|
val completedSessionId = mutableStateOf<String?>(null)
|
||||||
compose.setContent { TrainlogTheme {
|
compose.setContent { TrainlogTheme {
|
||||||
completedSessionId.value?.let { SessionDetailScreen(repository, it, {}, {}) }
|
completedSessionId.value?.let { SessionDetailScreen(repository, it, {}, {}) }
|
||||||
?: SessionScreen(repository, 0, {}, {}, {})
|
?: SessionScreen(repository, 0, {}, {}, { _ -> }, {})
|
||||||
} }
|
} }
|
||||||
compose.onNodeWithTag("draft-feedback-${entry.entryId}").fetchSemanticsNode()
|
compose.onNodeWithTag("draft-feedback-${entry.entryId}").fetchSemanticsNode()
|
||||||
compose.onNodeWithText("Ressenti · texte UI durable").fetchSemanticsNode()
|
compose.onNodeWithText("Ressenti · texte UI durable").fetchSemanticsNode()
|
||||||
|
|
|
||||||
|
|
@ -7,15 +7,21 @@ package com.labfytools.trainlog.ui
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import androidx.compose.ui.test.junit4.createComposeRule
|
import androidx.compose.ui.test.junit4.createComposeRule
|
||||||
|
import androidx.compose.ui.test.assertIsDisplayed
|
||||||
import androidx.compose.ui.test.hasClickAction
|
import androidx.compose.ui.test.hasClickAction
|
||||||
import androidx.compose.ui.test.hasText
|
import androidx.compose.ui.test.hasText
|
||||||
import androidx.compose.ui.test.onNodeWithText
|
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.performSemanticsAction
|
||||||
|
import androidx.compose.ui.test.performScrollTo
|
||||||
import androidx.compose.ui.semantics.SemanticsActions
|
import androidx.compose.ui.semantics.SemanticsActions
|
||||||
import androidx.test.core.app.ApplicationProvider
|
import androidx.test.core.app.ApplicationProvider
|
||||||
import com.labfytools.trainlog.data.ActiveDraftMutationResult
|
import com.labfytools.trainlog.data.ActiveDraftMutationResult
|
||||||
|
import com.labfytools.trainlog.data.ActiveDraftLoadResult
|
||||||
import com.labfytools.trainlog.data.BodyZoneRecentExposure
|
import com.labfytools.trainlog.data.BodyZoneRecentExposure
|
||||||
import com.labfytools.trainlog.data.ExposureWindowSummary
|
import com.labfytools.trainlog.data.ExposureWindowSummary
|
||||||
|
import com.labfytools.trainlog.data.CreateExerciseResult
|
||||||
import com.labfytools.trainlog.data.GenerationWarningLevel
|
import com.labfytools.trainlog.data.GenerationWarningLevel
|
||||||
import com.labfytools.trainlog.data.SessionGenerationPreview
|
import com.labfytools.trainlog.data.SessionGenerationPreview
|
||||||
import com.labfytools.trainlog.data.SessionGenerationPreviewExercise
|
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.SyncRequestOutbox
|
||||||
import com.labfytools.trainlog.data.TrainlogRepository
|
import com.labfytools.trainlog.data.TrainlogRepository
|
||||||
import com.labfytools.trainlog.model.SessionExercisePlan
|
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 com.labfytools.trainlog.ui.theme.TrainlogTheme
|
||||||
import java.security.MessageDigest
|
import java.security.MessageDigest
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
|
@ -109,6 +122,83 @@ class TrainlogAppNavigationCallbackTest {
|
||||||
assertArrayEquals(before, databaseDigest())
|
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")
|
private fun databaseDigest(): ByteArray = MessageDigest.getInstance("SHA-256")
|
||||||
.digest(context.getDatabasePath(databaseName).readBytes())
|
.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
|
be separated with `;`. Assisted equipment is explicitly labelled
|
||||||
`Assistance (kg)`. Empty load remains distinct from an entered zero.
|
`Assistance (kg)`. Empty load remains distinct from an entered zero.
|
||||||
|
|
||||||
`Nouvelle machine` in that same selector creates a persistent local custom
|
`Créer un équipement` in that selector opens the existing catalogue creation
|
||||||
equipment entry with a generated stable `eq_…` ID and selects it immediately.
|
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
|
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
|
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.
|
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
|
- exactly one durable active session draft with identity-preserving continuous
|
||||||
multi-position drag reorder,
|
multi-position drag reorder,
|
||||||
resume, confirmed discard, and atomic completion;
|
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
|
- completed-session consultation, transient drag reorder, and atomic factual or
|
||||||
order correction while retaining session/occurrence/feedback identities for
|
order correction while retaining session/occurrence/feedback identities for
|
||||||
synchronization;
|
synchronization;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue