fix(android): create equipment from active editor

This commit is contained in:
fy59 2026-09-21 14:25:19 +02:00
parent c5e62c2e5e
commit e320767516
13 changed files with 215 additions and 40 deletions

View file

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

View file

@ -60,6 +60,7 @@ class DraftUiTestActivity : ComponentActivity() {
sessionVisible = false
},
onCreateExercise = {},
onCreateEquipment = { _ -> },
/* CONTRACT: this host never writes shared export data. */
onSessionSaved = {},
)

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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