feat: refine Android training UI and sync workflow

This commit is contained in:
fy59 2026-09-14 20:07:30 +02:00
parent 48d163b0a6
commit c15fdb53b3
19 changed files with 538 additions and 98 deletions

View file

@ -1,5 +1,20 @@
# Changelog
- Fixed fresh Android database body-zone reconstruction by applying the
PC-published body-zone companion immediately after catalog, flattened aliases
and strict profile-state, before fallible downstream session, equipment, AI
draft and feedback imports. Regression coverage rebuilds several mappings in
one inbox pass, resolves a retired exercise ID only through its durable alias,
preserves the one-sync AI draft, and proves idempotent replay without identity
mutation or duplicate zone rows.
- Redesigned the Android Home, Sessions, Exercise Catalogue, active-session
and completed-session-detail action layouts with balanced icon tiles,
bounded internal catalogue scrolling, and prominent accessible buttons,
plus uniform full-width icon-only destructive actions with contextual
TalkBack labels, without changing confirmation, navigation callbacks, or
domain/storage/sync behavior.
- Added an Android confirmation dialog before deleting an imported AI session
draft. Cancel, Back and outside dismissal are non-mutating; only the
destructive confirmation invokes the existing deleted-tombstone operation.

View file

@ -233,7 +233,7 @@ class SessionDraftUiInstrumentedTest {
compose.onNodeWithTag("delete-set-1").performScrollTo().performClick()
compose.onNodeWithText("Supprimer la série 1 ?").assertIsDisplayed()
compose.onNodeWithTag("session-set-0-reps").assertTextEquals("4")
compose.onNodeWithText("Supprimer").performClick()
compose.onNodeWithTag("confirm-destructive-delete").performClick()
compose.onNodeWithText("Ajouter une série").performScrollTo().performClick()
compose.onNodeWithTag("session-set-0-reps").performScrollTo().assertTextEquals("9")

View file

@ -103,12 +103,19 @@ class SyncCatalogInbox(
is ExerciseProfileStateImportResult.Conflict -> return CatalogInboxResult.Error("Conflit de profil : ${profile.exerciseId}")
ExerciseProfileStateImportResult.DatabaseError -> return CatalogInboxResult.Error("Erreur base locale profils.")
}
/* Catalogue identities now exist and the strict second pass
* has either installed or verified their causal state. */
/* WHY: body zones are reconstruction prerequisites, not a
* best-effort downstream decoration. If a later companion
* rejects the inbox, a freshly catalogued database must
* not remain unzoned and subsequently publish that empty
* state as a valid bidirectional edit.
* CONTRACT: catalog, flattened aliases and strict profile
* state establish every canonical identity before zones;
* all session/equipment/AI companions follow them.
* INVARIANT: zone identity resolution is ID/alias-only. */
importPcBodyZones(directory)?.let { return CatalogInboxResult.Error(it) }
importPcSessions(directory)?.let { return CatalogInboxResult.Error(it) }
importPcEquipmentAssociations(directory)?.let { return CatalogInboxResult.Error(it) }
importAiSessionDrafts(directory)?.let { return CatalogInboxResult.Error(it) }
importPcBodyZones(directory)?.let { return CatalogInboxResult.Error(it) }
importTrainingFeedback(directory)?.let { return CatalogInboxResult.Error(it) }
CatalogInboxResult.Imported(
imported = result.imported,

View file

@ -88,17 +88,17 @@ fun CompletedSessionCorrectionScreen(
if (set.weightKg != null) TrainlogInputField("Charge (kg)", set.weightKg.toString(), { raw -> raw.toDoubleOrNull()?.let { value ->
replaceSet(drafts, occurrenceIndex, setIndex, set.copy(weightKg = value)) { drafts = it }
} })
TrainlogIconAction(TrainlogIcons.DeleteOutline, "Supprimer cette série",
TrainlogDeleteButton("Supprimer cette série",
{ pendingSetRemoval = occurrenceIndex to setIndex },
Modifier.testTag("remove-completed-set-${draft.entryId}-$setIndex"), colors.error)
Modifier.testTag("remove-completed-set-${draft.entryId}-$setIndex"))
}
TrainlogAction("+ Ajouter une série", "Ajouter explicitement un fait réalisé.", {
replaceDraft(drafts, occurrenceIndex, draft.copy(sets = draft.sets + SessionSetDraft())) { drafts = it }
}, accent = colors.success)
}
TrainlogIconAction(TrainlogIcons.DeleteOutline, "Supprimer cette occurrence",
TrainlogDeleteButton("Supprimer cette occurrence",
{ pendingOccurrenceRemoval = occurrenceIndex },
Modifier.testTag("remove-completed-occurrence-${draft.entryId}"), colors.error)
Modifier.testTag("remove-completed-occurrence-${draft.entryId}"))
}
}
message?.let { TrainlogInfo(it, colors.error) }
@ -112,7 +112,8 @@ fun CompletedSessionCorrectionScreen(
TrainlogAction("Annuler", "Quitter sans modifier la base.", { onFinished(false) }, accent = colors.muted)
}
pendingSetRemoval?.let { (occurrence, set) ->
DestructiveConfirmationDialog("Supprimer cette série ?", "Ce fait réalisé disparaîtra de la séance corrigée.", "Supprimer", { pendingSetRemoval = null }) {
DestructiveConfirmationDialog("Supprimer cette série ?", "Ce fait réalisé disparaîtra de la séance corrigée.", "Supprimer", { pendingSetRemoval = null },
deleteContentDescription = "Supprimer cette série") {
val draft = drafts[occurrence]
replaceDraft(drafts, occurrence, draft.copy(sets = draft.sets.filterIndexed { index, _ -> index != set })) { drafts = it }
pendingSetRemoval = null
@ -122,7 +123,8 @@ fun CompletedSessionCorrectionScreen(
val draft = drafts[occurrence]
DestructiveConfirmationDialog("Supprimer cette occurrence ?",
"Impact : ${draft.sets.size} série(s) réalisée(s)${if (draft.continuousDurationSeconds > 0) ", activité continue" else ""}${if (draft.maxWeightKg != null) ", MAX" else ""}. Ses ressentis et révisions seront supprimés avec leur parent.",
"Supprimer", { pendingOccurrenceRemoval = null }) {
"Supprimer", { pendingOccurrenceRemoval = null },
deleteContentDescription = "Supprimer cette occurrence") {
drafts = drafts.filterIndexed { index, _ -> index != occurrence }
pendingOccurrenceRemoval = null
}

View file

@ -6,7 +6,10 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.foundation.text.BasicText
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Text
@ -18,6 +21,7 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.semantics.selected
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontWeight
@ -678,11 +682,24 @@ fun ExerciseCatalogueScreen(
}
TrainlogFrame("Catalogue", active = exercises.isNotEmpty()) {
if (exercises.isEmpty()) TrainlogInfo("Aucun exercice trouvé.")
exercises.forEach { exercise ->
TrainlogAction(exercise.name, exerciseZoneSummary(repository, exercise), {
state.selectedExerciseId = exercise.exerciseId
onOpenDetail(exercise.exerciseId)
})
if (exercises.isNotEmpty()) {
/* CONTRACT: filtering and ordering stay repository-owned. This
* fixed viewport bounds presentation only; its child owns the
* catalogue scroll so filters remain visible above it. */
Column(
Modifier
.fillMaxWidth()
.height(240.dp)
.verticalScroll(rememberScrollState())
.testTag("exercise-catalogue-scroll"),
) {
exercises.forEach { exercise ->
TrainlogAction(exercise.name, exerciseZoneSummary(repository, exercise), {
state.selectedExerciseId = exercise.exerciseId
onOpenDetail(exercise.exerciseId)
})
}
}
}
}
}

View file

@ -10,6 +10,8 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
@ -88,8 +90,13 @@ fun HomeScreen(
TrainlogInfo("Dernier MAX · ${maximum.exerciseName} · $weight kg · ${maximum.startedAt.take(10)}", colors.warning)
}
TrainlogFrame("Accès rapides", active = false) {
TrainlogAction("Mensurations", "Ajouter ou consulter les relevés locaux.", onBody)
TrainlogAction("Synchronisation", "Consulter l'état connu et lancer une action explicite.", onSync)
Row(Modifier.fillMaxWidth().height(IntrinsicSize.Min).testTag("home-quick-actions-row"),
horizontalArrangement = Arrangement.spacedBy(10.dp)) {
TrainlogActionTile("Synchronisation", "État et actions", TrainlogIcons.Refresh,
onSync, Modifier.weight(1f).fillMaxHeight().testTag("home-sync-action"))
TrainlogActionTile("Mensurations", "Suivi corporel", TrainlogIcons.BodyMeasurements,
onBody, Modifier.weight(1f).fillMaxHeight().testTag("home-body-action"))
}
}
}
}

View file

@ -7,6 +7,15 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.unit.dp
import androidx.compose.ui.platform.LocalContext
import com.labfytools.trainlog.data.CreateEquipmentResult
import com.labfytools.trainlog.data.CatalogInboxResult
@ -37,14 +46,17 @@ fun SessionsHub(
else TrainlogAction("Reprendre", "${draft.exercises.size} exercice(s) · le brouillon durable est conservé.", onResume, accent = colors.success)
}
TrainlogFrame("Préparer") {
TrainlogAction("Nouvelle séance manuelle", if (draft == null) "Créer explicitement un brouillon de séance." else "Ouvrir la séance en cours sans l'écraser.", onManual)
TrainlogAction(
"Brouillons",
"$pendingAiDraftCount séance(s) préparée(s) · consulter les propositions importées depuis le PC.",
onDrafts,
)
Row(Modifier.fillMaxWidth().height(IntrinsicSize.Min).testTag("sessions-prepare-row"),
horizontalArrangement = Arrangement.spacedBy(10.dp)) {
TrainlogActionTile("Nouvelle séance manuelle", "Créer ou reprendre", TrainlogIcons.Edit,
onManual, Modifier.weight(1f).fillMaxHeight().testTag("sessions-manual-action"))
TrainlogActionTile("Brouillons", "$pendingAiDraftCount séance(s) préparée(s)",
TrainlogIcons.Drafts, onDrafts,
Modifier.weight(1f).fillMaxHeight().testTag("sessions-drafts-action"))
}
}
TrainlogAction("Séances effectuées", "Consulter les actuals, plans et MAX enregistrés.", onHistory)
TrainlogButton("Séances effectuées", onHistory,
Modifier.fillMaxWidth().testTag("sessions-history-action"))
}
}
@ -93,13 +105,12 @@ fun AiSessionDraftsScreen(
is StartAiSessionDraftResult.Error -> message = result.message
}
}
TrainlogAction(
"Supprimer",
"Conserver un tombstone pour empêcher sa réapparition lors d'un replay.",
TrainlogDeleteButton(
contentDescription = "Supprimer ce brouillon",
modifier = Modifier.testTag("delete-ai-draft-${draft.draftId}"),
onClick = {
pendingDeletion = draft.draftId to (draft.title ?: "Proposition de séance")
},
accent = colors.error,
)
}
}
@ -111,6 +122,7 @@ fun AiSessionDraftsScreen(
confirmLabel = "Supprimer",
onCancel = { pendingDeletion = null },
dismissOnClickOutside = true,
deleteContentDescription = "Supprimer ce brouillon",
) {
/* CONTRACT: dialog confirmation is the sole UI path to the existing
* tombstone mutation. Dismissal never reaches the repository. */

View file

@ -7,6 +7,7 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.compose.foundation.layout.fillMaxWidth
import com.labfytools.trainlog.data.TrainlogRepository
import com.labfytools.trainlog.data.SaveFeedbackResult
import com.labfytools.trainlog.data.exerciseFeedbackElapsedLabel
@ -104,12 +105,8 @@ fun SessionDetailScreen(
"${detail.summary.exerciseCount} exercice(s)"
)
TrainlogAction(
label = "Modifier la séance",
description = "Corriger les faits enregistrés sans changer l'identité de la séance.",
accent = colors.accent,
onClick = onCorrect,
)
TrainlogButton("Modifier la séance", onCorrect,
Modifier.fillMaxWidth().testTag("edit-completed-session"))
if (detail.summary.sessionType == SessionType.MAX_TEST) {
TrainlogAction(
@ -154,10 +151,8 @@ fun SessionDetailScreen(
value = equipmentQuery,
onValueChange = { equipmentQuery = it },
)
TrainlogIconAction(
icon = TrainlogIcons.DeleteOutline,
TrainlogDeleteButton(
contentDescription = "Retirer la machine de cet exercice",
accent = colors.warning,
modifier = Modifier.testTag("delete-completed-equipment-${exercise.entryId}"),
onClick = {
pendingEquipmentChange = exercise.entryId to null
@ -228,12 +223,9 @@ fun SessionDetailScreen(
}
}, onCancel = { feedbackEntryId = null })
} else {
TrainlogAction(
label = if (exercise.feedback.isEmpty()) "🎙 Ressenti" else "+ Ajouter un ressenti",
description = "Ajouter une observation subjective à cette occurrence.",
accent = colors.accent,
onClick = { feedbackEntryId = exercise.entryId },
)
TrainlogButton("Ajouter un ressenti", { feedbackEntryId = exercise.entryId },
Modifier.fillMaxWidth().testTag("add-exercise-feedback-${exercise.entryId}"),
icon = TrainlogIcons.Mic)
}
}
}
@ -260,9 +252,9 @@ fun SessionDetailScreen(
is SaveFeedbackResult.DatabaseError -> result.message
}
}, onCancel = { addingFollowUp = false })
} else TrainlogAction("+ Ajouter un enregistrement",
"Ajouter une observation chronologique sans l’attribuer à un exercice.",
onClick = { addingFollowUp = true }, accent = colors.success)
} else TrainlogButton("Ajouter un enregistrement", { addingFollowUp = true },
Modifier.fillMaxWidth().testTag("add-session-follow-up"), icon = TrainlogIcons.Mic,
containerColor = colors.success)
}
pendingEquipmentChange?.let { (entryId, equipmentId) ->
DestructiveConfirmationDialog(
@ -270,6 +262,9 @@ fun SessionDetailScreen(
detail = "L’association de machine actuellement enregistrée sera perdue. Les séries, MAX et ressentis restent inchangés.",
confirmLabel = if (equipmentId == null) "Retirer" else "Remplacer",
onCancel = { pendingEquipmentChange = null },
deleteContentDescription = if (equipmentId == null) {
"Retirer la machine de cet exercice"
} else null,
onConfirm = {
if (repository.setCompletedSessionEquipment(detail.summary.sessionId,entryId,equipmentId)) {
revision++;editingEntryId=null;equipmentQuery="";pendingEquipmentChange=null

View file

@ -287,9 +287,9 @@ fun SessionGeneratorScreen(
TrainlogAction("Descendre", "Déplacer cet exercice après le suivant.", onClick = {
if (!busy) preview = SessionGeneratorPreviewController.move(current, index, 1)
}, accent = colors.muted)
TrainlogAction("Retirer", "Retirer cet exercice de la proposition uniquement.", onClick = {
TrainlogDeleteButton("Retirer cet exercice de la proposition", onClick = {
if (!busy) preview = SessionGeneratorPreviewController.remove(current, index)
}, accent = colors.error)
})
}
}
TrainlogAction("Régénérer", "Relancer les mêmes entrées et le même instant de référence.", onClick = { generate(current.request) })

View file

@ -10,8 +10,12 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
@ -378,10 +382,8 @@ fun SessionScreen(
}
if (currentDraft.sourceSessionId == null) {
TrainlogIconAction(
icon = TrainlogIcons.DeleteOutline,
TrainlogDeleteButton(
contentDescription = "Supprimer cet exercice",
accent = colors.error,
modifier = Modifier.testTag("delete-draft-exercise-${draft.entryId}"),
onClick = {
pendingRemoval = index to draft
@ -459,33 +461,16 @@ fun SessionScreen(
}
TrainlogFrame(
title = "Exercices"
) {
TrainlogAction(
label =
"Créer un nouvel exercice",
description =
"Créer l'exercice sans quitter la saisie de séance.",
onClick =
onCreateExercise,
accent =
colors.success,
)
}
TrainlogFrame(
title = "Enregistrement",
title = "Actions",
active =
currentDraft.exercises.isNotEmpty(),
) {
TrainlogAction(
label =
"Enregistrer la séance",
description =
"${currentDraft.exercises.size} exercice(s) dans la séance.",
accent =
colors.success,
onClick = {
Row(Modifier.fillMaxWidth().height(IntrinsicSize.Min).testTag("active-session-actions-row"),
horizontalArrangement = Arrangement.spacedBy(8.dp)) {
TrainlogButton("Créer un nouvel exercice", onCreateExercise,
Modifier.weight(1f).fillMaxHeight().testTag("active-session-create-exercise"),
containerColor = colors.surfaceAlt, maxLines = 2)
TrainlogButton("Enregistrer la séance", onClick = {
when (
val result =
repository.finalizeActiveSessionDraft()
@ -505,13 +490,13 @@ fun SessionScreen(
result.message
}
}
},
)
}, modifier = Modifier.weight(1f).fillMaxHeight().testTag("active-session-save"),
containerColor = colors.success, maxLines = 2)
}
TrainlogInfo("${currentDraft.exercises.size} exercice(s) dans la séance.", colors.muted)
TrainlogIconAction(
icon = TrainlogIcons.DeleteOutline,
TrainlogDeleteButton(
contentDescription = "Supprimer la séance en cours",
accent = colors.error,
modifier = Modifier.testTag("delete-active-draft"),
onClick = {
confirmingDiscard = true
@ -524,6 +509,7 @@ fun SessionScreen(
detail = "Cette action supprimera le brouillon, ses ${currentDraft.exercises.size} exercice(s) et toutes leurs données enregistrées.",
confirmLabel = "Abandonner",
onCancel = { confirmingDiscard = false },
deleteContentDescription = "Supprimer la séance en cours",
onConfirm = {
when (
val result =
@ -578,6 +564,7 @@ fun SessionScreen(
"Cette action supprimera :\n- " + facts.joinToString("\n- "),
confirmLabel = "Supprimer",
onCancel = { pendingRemoval = null },
deleteContentDescription = "Supprimer cet exercice",
onConfirm = {
persistDraft(currentDraft.copy(exercises = currentDraft.exercises.filterIndexed { i, _ -> i != index }),
"Exercice retiré de la séance.")
@ -1139,10 +1126,8 @@ private fun SessionExerciseForm(
)
},
)
TrainlogIconAction(
icon = TrainlogIcons.DeleteOutline,
TrainlogDeleteButton(
contentDescription = "Supprimer cette série",
accent = colors.error,
modifier = Modifier.testTag("delete-set-${index + 1}"),
onClick = {
pendingSetRemoval = index
@ -1387,6 +1372,7 @@ private fun SessionExerciseForm(
detail = "Ses répétitions, sa durée et sa charge saisies seront supprimées.",
confirmLabel = "Supprimer",
onCancel = { pendingSetRemoval = null },
deleteContentDescription = "Supprimer cette série",
onConfirm = {
val updated=setRows.filterIndexed { rowIndex, _ -> rowIndex != index }
setRows=updated;repsText=encodeRawReps(updated);weightText=encodeRawWeights(updated);error=null

View file

@ -18,6 +18,7 @@ import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.BasicText
import androidx.compose.foundation.text.BasicTextField
@ -204,6 +205,27 @@ fun TrainlogIconAction(
}
}
/**
* CONTRACT: the icon is the only visible destructive label; the contextual
* accessible name remains mandatory and the caller retains confirmation and
* mutation ownership.
*/
@Composable
fun TrainlogDeleteButton(
contentDescription: String,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
val colors = LocalTrainlogColors.current
Button(
onClick = onClick,
modifier = modifier.fillMaxWidth().heightIn(min = 52.dp),
colors = ButtonDefaults.buttonColors(containerColor = colors.error),
) {
Icon(TrainlogIcons.DeleteOutline, contentDescription = contentDescription)
}
}
@Composable
fun TrainlogPrimaryAction(label: String, description: String, onClick: () -> Unit) {
val colors = LocalTrainlogColors.current
@ -221,6 +243,58 @@ fun TrainlogPrimaryAction(label: String, description: String, onClick: () -> Uni
}
}
/**
* CONTRACT: a visual tile keeps the supplied callback as the sole action; it
* adds no navigation or business state. Equal weights are supplied by callers
* when sibling tiles must share a row on narrow screens.
*/
@Composable
fun TrainlogActionTile(
label: String,
description: String,
icon: ImageVector,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
val colors = LocalTrainlogColors.current
Column(
modifier
.heightIn(min = 112.dp)
.background(colors.surface, RoundedCornerShape(12.dp))
.clickable(onClick = onClick)
.padding(12.dp),
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
Icon(icon, contentDescription = null, tint = colors.accent)
Text(label, color = colors.accent, fontWeight = FontWeight.Bold)
BasicText(description, style = TrainlogTypography.small.copy(color = colors.text))
}
}
/** Prominent action whose modifier controls full-width or equal-row sizing. */
@Composable
fun TrainlogButton(
label: String,
onClick: () -> Unit,
modifier: Modifier = Modifier,
icon: ImageVector? = null,
containerColor: Color? = null,
maxLines: Int = 1,
) {
Button(
onClick = onClick,
modifier = modifier.heightIn(min = 52.dp),
colors = containerColor?.let { ButtonDefaults.buttonColors(containerColor = it) }
?: ButtonDefaults.buttonColors(),
contentPadding = PaddingValues(horizontal = 10.dp, vertical = 8.dp),
) {
icon?.let {
Icon(it, contentDescription = null, modifier = Modifier.padding(end = 6.dp))
}
Text(label, maxLines = maxLines)
}
}
/** Compact horizontally scrollable choices; selection is textual and colored. */
@Composable
fun TrainlogChoiceChips(
@ -370,7 +444,7 @@ fun TrainlogInfo(
}
/** Shared destructive commit gate. CONTRACT: dismissal and Back are safe,
* outside taps cannot confirm, and only the explicit labelled button invokes
* outside taps cannot confirm, and only the explicit accessible button invokes
* the destructive callback. */
@Composable
fun DestructiveConfirmationDialog(
@ -379,6 +453,7 @@ fun DestructiveConfirmationDialog(
confirmLabel: String,
onCancel: () -> Unit,
dismissOnClickOutside: Boolean = false,
deleteContentDescription: String? = null,
onConfirm: () -> Unit,
) {
val colors = LocalTrainlogColors.current
@ -388,10 +463,18 @@ fun DestructiveConfirmationDialog(
text = { Text(detail) },
dismissButton = { TextButton(onClick = onCancel) { Text("Annuler") } },
confirmButton = {
TextButton(
onClick = onConfirm,
colors = ButtonDefaults.textButtonColors(contentColor = colors.error),
) { Text(confirmLabel) }
if (deleteContentDescription != null) {
TrainlogDeleteButton(
deleteContentDescription,
onConfirm,
Modifier.testTag("confirm-destructive-delete"),
)
} else {
TextButton(
onClick = onConfirm,
colors = ButtonDefaults.textButtonColors(contentColor = colors.error),
) { Text(confirmLabel) }
}
},
properties = DialogProperties(
dismissOnBackPress = true,

View file

@ -6,8 +6,62 @@ import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.graphics.vector.path
import androidx.compose.ui.unit.dp
/** The two voice glyphs are local to avoid shipping the large extended icon catalog. */
/** Local UI glyphs avoid shipping the large extended Material icon catalog. */
internal object TrainlogIcons {
val Refresh: ImageVector by lazy {
ImageVector.Builder("Refresh", 24.dp, 24.dp, 24f, 24f).apply {
path(fill = SolidColor(Color.Black)) {
moveTo(17.65f, 6.35f); curveTo(16.2f, 4.9f, 14.21f, 4f, 12f, 4f)
curveTo(7.58f, 4f, 4.01f, 7.58f, 4.01f, 12f)
reflectiveCurveTo(7.58f, 20f, 12f, 20f)
curveTo(15.73f, 20f, 18.84f, 17.45f, 19.73f, 14f)
horizontalLineTo(17.65f); curveTo(16.83f, 16.33f, 14.61f, 18f, 12f, 18f)
curveTo(8.69f, 18f, 6f, 15.31f, 6f, 12f); reflectiveCurveTo(8.69f, 6f, 12f, 6f)
curveTo(13.66f, 6f, 15.14f, 6.69f, 16.22f, 7.78f)
lineTo(13f, 11f); horizontalLineTo(20f); verticalLineTo(4f); close()
}
}.build()
}
val BodyMeasurements: ImageVector by lazy {
ImageVector.Builder("BodyMeasurements", 24.dp, 24.dp, 24f, 24f).apply {
path(fill = SolidColor(Color.Black)) {
moveTo(7f, 3f); horizontalLineTo(17f); curveTo(19.21f, 3f, 21f, 4.79f, 21f, 7f)
verticalLineTo(17f); curveTo(21f, 19.21f, 19.21f, 21f, 17f, 21f)
horizontalLineTo(7f); curveTo(4.79f, 21f, 3f, 19.21f, 3f, 17f)
verticalLineTo(7f); curveTo(3f, 4.79f, 4.79f, 3f, 7f, 3f); close()
moveTo(12f, 6f); curveTo(9.79f, 6f, 8f, 7.79f, 8f, 10f)
horizontalLineTo(10f); curveTo(10f, 8.9f, 10.9f, 8f, 12f, 8f)
reflectiveCurveTo(14f, 8.9f, 14f, 10f); horizontalLineTo(16f)
curveTo(16f, 7.79f, 14.21f, 6f, 12f, 6f); close()
}
}.build()
}
val Edit: ImageVector by lazy {
ImageVector.Builder("Edit", 24.dp, 24.dp, 24f, 24f).apply {
path(fill = SolidColor(Color.Black)) {
moveTo(3f, 17.25f); verticalLineTo(21f); horizontalLineTo(6.75f)
lineTo(17.81f, 9.94f); lineTo(14.06f, 6.19f); close()
moveTo(20.71f, 7.04f); curveTo(21.1f, 6.65f, 21.1f, 6.02f, 20.71f, 5.63f)
lineTo(18.37f, 3.29f); curveTo(17.98f, 2.9f, 17.35f, 2.9f, 16.96f, 3.29f)
lineTo(15.13f, 5.12f); lineTo(18.88f, 8.87f); close()
}
}.build()
}
val Drafts: ImageVector by lazy {
ImageVector.Builder("Drafts", 24.dp, 24.dp, 24f, 24f).apply {
path(fill = SolidColor(Color.Black)) {
moveTo(14f, 2f); horizontalLineTo(6f); curveTo(4.9f, 2f, 4f, 2.9f, 4f, 4f)
verticalLineTo(20f); curveTo(4f, 21.1f, 4.9f, 22f, 6f, 22f)
horizontalLineTo(18f); curveTo(19.1f, 22f, 20f, 21.1f, 20f, 20f)
verticalLineTo(8f); close(); moveTo(13f, 9f); verticalLineTo(3.5f)
lineTo(18.5f, 9f); close()
}
}.build()
}
val Mic: ImageVector by lazy {
ImageVector.Builder(
name = "Mic",

View file

@ -3,6 +3,7 @@ package com.labfytools.trainlog.data
import android.Manifest
import android.content.Context
import android.content.pm.PackageManager
import android.database.sqlite.SQLiteDatabase
import android.provider.Settings
import androidx.test.core.app.ApplicationProvider
import java.io.File
@ -121,6 +122,125 @@ class DirectExchangeStorageTest {
}
}
@Test fun `fresh database reconstructs aliased body zones in one inbox pass and replay`() {
val directory = Files.createTempDirectory("trainlog-direct-zones-").toFile()
val sourceName = "direct-zones-source-${UUID.randomUUID()}.db"
val destinationName = "direct-zones-destination-${UUID.randomUUID()}.db"
val interruptedName = "direct-zones-interrupted-${UUID.randomUUID()}.db"
val source = TrainlogRepository(context, sourceName)
val destination = TrainlogRepository(context, destinationName)
val interrupted = TrainlogRepository(context, interruptedName)
val retiredId = "ex_33333333-3333-4333-8333-333333333333"
try {
fun create(name: String, primary: String, secondary: List<String> = emptyList()) =
source.createExercise(com.labfytools.trainlog.model.NewExerciseProfile(
name = name,
recordingMode = com.labfytools.trainlog.model.RecordingMode.SETS,
trackingMode = com.labfytools.trainlog.model.TrackingMode.REPS,
dataFields = 0,
primaryZoneId = primary,
secondaryZoneIds = secondary,
)) as CreateExerciseResult.Created
val canonical = create("Presse canonique", "thighs", listOf("glutes")).exercise
val second = create("Tirage canonique", "back", listOf("arms")).exercise
val third = create("Développé canonique", "chest", listOf("shoulders", "arms")).exercise
val aliasJson = JSONObject().put("format", "trainlog-exercise-aliases")
.put("version", 1).put("aliases", JSONArray().put(JSONObject()
.put("source_exercise_id", retiredId)
.put("canonical_exercise_id", canonical.exerciseId))).toString()
assertEquals(ExerciseAliasImportResult.Applied(1, 0),
source.applyExerciseAliasesJson(aliasJson))
fun catalogExercise(exercise: com.labfytools.trainlog.model.ExerciseProfile) = JSONObject()
.put("exercise_id", exercise.exerciseId).put("name", exercise.name)
.put("recording_mode", exercise.recordingMode.wireValue)
.put("tracking_mode", exercise.trackingMode.wireValue)
.put("data_fields", exercise.dataFields)
File(directory, "trainlog-pc-catalog-v1.json").writeText(
JSONObject().put("format", "trainlog-pc-catalog").put("version", 1)
.put("exercises", JSONArray().put(catalogExercise(canonical))
.put(catalogExercise(second)).put(catalogExercise(third))).toString(),
)
File(directory, "trainlog-exercise-aliases-v1.json").writeText(aliasJson)
File(directory, "trainlog-exercise-profile-state-v1.json")
.writeText(source.buildExerciseProfileStateJson())
val zones = JSONObject(source.buildExerciseBodyZonesJson())
val zoneItems = zones.getJSONArray("exercises")
for (index in 0 until zoneItems.length()) {
val item = zoneItems.getJSONObject(index)
if (item.getString("exercise_id") == canonical.exerciseId) {
item.put("exercise_id", retiredId)
}
}
File(directory, "trainlog-exercise-body-zones-v1.json").writeText(zones.toString())
val target = JSONObject().put("sets", 3).put("reps", 10)
.put("duration_seconds", JSONObject.NULL).put("weight_kg", JSONObject.NULL)
val draftEntry = JSONObject().put("entry_id", "sxe_abcdefab-cdef-4abc-8abc-abcdefabcdef")
.put("position", 0).put("exercise_id", retiredId)
.put("recording_mode", "sets").put("tracking_mode", "reps")
.put("data_fields", 0).put("equipment_id", JSONObject.NULL)
.put("load_mode", "none").put("rest_seconds", 90).put("target", target)
val draft = JSONObject().put("draft_id", "aid_12345678-1234-4abc-8abc-123456789abc")
.put("created_at", "2026-09-14T08:00:00Z").put("planned_for", "2026-09-15")
.put("session_type", "training").put("title", "Reconstruction")
.put("notes", "Test one-sync").put("entries", JSONArray().put(draftEntry))
File(directory, "trainlog-ai-session-drafts-v1.json").writeText(
JSONObject().put("format", "trainlog-ai-session-drafts").put("version", 1)
.put("generated_at", "2026-09-14T08:00:00Z")
.put("drafts", JSONArray().put(draft)).toString(),
)
val malformedSessions = File(directory, "trainlog-pc-mobile-export-v3.json")
malformedSessions.writeText("{\"format\":\"trainlog-mobile-export\",\"version\":3}")
val inbox = SyncCatalogInbox(context, destination)
/* A downstream failure must not leave a freshly catalogued peer
* unzoned: otherwise its next bidirectional publication can make
* that accidental empty state authoritative. */
assertTrue(SyncCatalogInbox(context, interrupted)
.importPcCatalogFromDirectoryForTest(directory) is CatalogInboxResult.Error)
assertEquals(listOf("thighs", "back", "chest"), interrupted.listExercises()
.associateBy { it.exerciseId }.let { exercises -> listOf(
exercises.getValue(canonical.exerciseId).primaryZoneId,
exercises.getValue(second.exerciseId).primaryZoneId,
exercises.getValue(third.exerciseId).primaryZoneId,
) })
assertTrue(malformedSessions.delete())
assertTrue(inbox.importPcCatalogFromDirectoryForTest(directory) is CatalogInboxResult.Imported)
val first = destination.listExercises().associateBy { it.exerciseId }
assertEquals(setOf(canonical.exerciseId, second.exerciseId, third.exerciseId), first.keys)
assertEquals("thighs", first.getValue(canonical.exerciseId).primaryZoneId)
assertEquals(listOf("glutes"), first.getValue(canonical.exerciseId).secondaryZoneIds)
assertEquals("back", first.getValue(second.exerciseId).primaryZoneId)
assertEquals(listOf("arms"), first.getValue(second.exerciseId).secondaryZoneIds)
assertEquals("chest", first.getValue(third.exerciseId).primaryZoneId)
assertEquals(listOf("shoulders", "arms"), first.getValue(third.exerciseId).secondaryZoneIds)
assertEquals(canonical.exerciseId,
destination.listAiSessionDrafts().single().entries.single().exercise.exerciseId)
assertTrue(inbox.importPcCatalogFromDirectoryForTest(directory) is CatalogInboxResult.Imported)
val replayed = destination.listExercises().associateBy { it.exerciseId }
assertEquals(first, replayed)
assertFalse(replayed.containsKey(retiredId))
SQLiteDatabase.openDatabase(context.getDatabasePath(destinationName).path, null,
SQLiteDatabase.OPEN_READONLY).use { db ->
db.rawQuery("SELECT COUNT(*) FROM exercise_body_zones", null).use { cursor ->
assertTrue(cursor.moveToFirst())
assertEquals(7, cursor.getInt(0))
}
}
} finally {
source.close()
destination.close()
interrupted.close()
context.deleteDatabase(sourceName)
context.deleteDatabase(destinationName)
context.deleteDatabase(interruptedName)
directory.deleteRecursively()
}
}
@Test fun `fresh catalog row adopts only initial resolved machine identity`() {
val databaseName = "direct-profile-${UUID.randomUUID()}.db"
val id = "ex_33333333-3333-4333-8333-333333333333"

View file

@ -5,8 +5,12 @@ import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.onAllNodesWithText
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performSemanticsAction
import androidx.compose.ui.semantics.SemanticsActions
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.test.performKeyInput
@ -15,6 +19,7 @@ import androidx.compose.ui.test.performTouchInput
import androidx.compose.ui.test.click
import androidx.test.core.app.ApplicationProvider
import com.labfytools.trainlog.data.ActiveDraftLoadResult
import com.labfytools.trainlog.data.ActiveDraftMutationResult
import com.labfytools.trainlog.data.AiSessionDraftImportResult
import com.labfytools.trainlog.data.CreateExerciseResult
import com.labfytools.trainlog.data.TrainlogRepository
@ -70,20 +75,56 @@ class AiSessionDraftUiWiringTest {
@Test
fun sessionsHubShowsPendingCountAndPreservesManualAndHistoryActions() {
var manualCalls = 0
var draftCalls = 0
var historyCalls = 0
compose.setContent { TrainlogTheme {
SessionsHub(null, 2, {}, { manualCalls++ }, {}, { historyCalls++ })
SessionsHub(null, 2, {}, { manualCalls++ }, { draftCalls++ }, { historyCalls++ })
} }
compose.onNodeWithText("2 séance(s) préparée(s)", substring = true).assertIsDisplayed()
compose.onNodeWithText("Nouvelle séance manuelle").performClick()
compose.onNodeWithText("Brouillons").performClick()
compose.onNodeWithText("Séances effectuées").performClick()
compose.runOnIdle {
assertEquals(1, manualCalls)
assertEquals(1, draftCalls)
assertEquals(1, historyCalls)
}
}
@Test
fun sessionsHubKeepsPrepareActionsTogetherAndHistoryBelow() {
compose.setContent { TrainlogTheme { SessionsHub(null, 2, {}, {}, {}, {}) } }
val manual = compose.onNodeWithTag("sessions-manual-action").fetchSemanticsNode().boundsInRoot
val drafts = compose.onNodeWithTag("sessions-drafts-action").fetchSemanticsNode().boundsInRoot
val history = compose.onNodeWithTag("sessions-history-action").fetchSemanticsNode().boundsInRoot
assertEquals(manual.top, drafts.top, 1f)
assertEquals(manual.height, drafts.height, 1f)
assertEquals(manual.width, drafts.width, 1f)
assertTrue(history.top > manual.bottom)
}
@Test
fun activeSessionActionsAreEqualHeightButtonsAndKeepCreateCallback() {
assertEquals(ActiveDraftMutationResult.Saved, repository.startActiveSessionDraft())
var createCalls = 0
compose.setContent { TrainlogTheme {
SessionScreen(repository, 0, {}, { createCalls++ }, {})
} }
val create = compose.onNodeWithTag("active-session-create-exercise")
.fetchSemanticsNode().boundsInRoot
val save = compose.onNodeWithTag("active-session-save")
.fetchSemanticsNode().boundsInRoot
assertEquals(create.top, save.top, 1f)
assertEquals(create.height, save.height, 1f)
assertEquals(create.width, save.width, 1f)
compose.onNodeWithTag("active-session-create-exercise")
.performSemanticsAction(SemanticsActions.OnClick)
compose.runOnIdle { assertEquals(1, createCalls) }
}
@Test
fun draftScreenShowsDosBicepsAndTargetDetails() {
importDraft()
@ -158,9 +199,11 @@ class AiSessionDraftUiWiringTest {
AiSessionDraftsScreen(repository, externalRevision = 0, onStarted = {})
} }
compose.onNodeWithText("Supprimer").performClick()
compose.onNodeWithText("Supprimer").assertDoesNotExist()
compose.onNodeWithContentDescription("Supprimer ce brouillon").performClick()
compose.onNodeWithText("Supprimer ce brouillon ?").assertIsDisplayed()
compose.onNodeWithTag("confirm-destructive-delete").assertIsDisplayed()
compose.onNodeWithText("Le brouillon « Dos + biceps » sera supprimé.", substring = true)
.assertIsDisplayed()
compose.runOnIdle { assertEquals(1, repository.listAiSessionDrafts().size) }
@ -173,7 +216,7 @@ class AiSessionDraftUiWiringTest {
AiSessionDraftsScreen(repository, externalRevision = 0, onStarted = {})
} }
compose.onNodeWithText("Supprimer").performClick()
compose.onNodeWithContentDescription("Supprimer ce brouillon").performClick()
compose.onNodeWithText("Annuler").performClick()
compose.onNodeWithText("Dos + biceps").assertIsDisplayed()
@ -187,7 +230,7 @@ class AiSessionDraftUiWiringTest {
AiSessionDraftsScreen(repository, externalRevision = 0, onStarted = {})
} }
compose.onNodeWithText("Supprimer").performClick()
compose.onNodeWithContentDescription("Supprimer ce brouillon").performClick()
compose.onNodeWithText("Supprimer ce brouillon ?").performKeyInput {
pressKey(Key.Back)
}
@ -203,7 +246,7 @@ class AiSessionDraftUiWiringTest {
AiSessionDraftsScreen(repository, externalRevision = 0, onStarted = {})
} }
compose.onNodeWithText("Supprimer").performClick()
compose.onNodeWithContentDescription("Supprimer ce brouillon").performClick()
compose.onNodeWithText("Supprimer ce brouillon ?").performTouchInput {
click(Offset(-40f, -40f))
}
@ -225,9 +268,9 @@ class AiSessionDraftUiWiringTest {
)
} }
compose.onNodeWithText("Supprimer").performClick()
compose.onNodeWithContentDescription("Supprimer ce brouillon").performClick()
compose.onNodeWithText("Supprimer ce brouillon ?").assertIsDisplayed()
compose.onAllNodesWithText("Supprimer")[1].performClick()
compose.onNodeWithTag("confirm-destructive-delete").performClick()
compose.onNodeWithText("Aucun brouillon importé.").assertIsDisplayed()
compose.runOnIdle {

View file

@ -0,0 +1,61 @@
package com.labfytools.trainlog.ui
import androidx.compose.ui.test.junit4.createComposeRule
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.performSemanticsAction
import androidx.compose.ui.semantics.SemanticsActions
import com.labfytools.trainlog.data.BodyZoneHomeOverview
import com.labfytools.trainlog.ui.theme.TrainlogTheme
import org.junit.Assert.assertEquals
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [35])
class AndroidUiRedesignTest {
@Suppress("DEPRECATION")
@get:Rule val compose = createComposeRule()
@Test
fun homeQuickActionsShareARowAndKeepTheirCallbacks() {
var syncCalls = 0
var bodyCalls = 0
compose.setContent {
TrainlogTheme {
HomeScreen(
activeDraft = null,
draftError = null,
latestSession = null,
latestMaximum = null,
bodyZoneOverview = BodyZoneHomeOverview(emptyList(), emptyList(), false, false),
onSession = {},
onOpenExercises = {},
onOpenStatistics = {},
onBody = { bodyCalls++ },
onOpenLatestSession = {},
onSync = { syncCalls++ },
)
}
}
val sync = compose.onNodeWithTag("home-sync-action").fetchSemanticsNode().boundsInRoot
val body = compose.onNodeWithTag("home-body-action").fetchSemanticsNode().boundsInRoot
assertEquals(sync.top, body.top, 1f)
assertEquals(sync.height, body.height, 1f)
assertEquals(sync.width, body.width, 1f)
compose.onNode(hasText("Synchronisation") and hasClickAction())
.performSemanticsAction(SemanticsActions.OnClick)
compose.onNode(hasText("Mensurations") and hasClickAction())
.performSemanticsAction(SemanticsActions.OnClick)
compose.runOnIdle {
assertEquals(1, syncCalls)
assertEquals(1, bodyCalls)
}
}
}

View file

@ -103,6 +103,22 @@ canonical route and exposes selected semantics; it is not maintained as a
second navigation state. The drawer itself scrolls when vertical space is
limited.
The section screens use the same dark semantic theme and existing typed-route
callbacks. Home presents Synchronisation and Mensurations as two equal quick
action tiles. Sessions presents manual creation and imported drafts in one
row, with completed history below. The exercise catalogue keeps its search and
zone filters visible above a bounded, internally scrollable result viewport.
The active-session create/save actions share one button row, while completed
session edit, exercise feedback, and session follow-up actions use prominent
full-width buttons. These are presentation rules only; they do not introduce
navigation, repository, draft, synchronization, or persistence behavior.
All user-facing removal triggers use the shared full-width destructive button:
its only visible content is the trash icon, its container uses the semantic
error color, and its context-specific accessible name remains exposed to
TalkBack. Existing confirmation gates and their mutation callbacks are
unchanged. Side-by-side action rows use equal weights and an intrinsic common
height so the shorter action stretches to match its sibling.
## STATS_V1
The Statistics landing page defaults to 30 days and is a Material 3 dashboard using the existing
@ -529,6 +545,14 @@ and simultaneous divergence returns an explicit conflict without changing
local relations. The unclassified state contains neither a primary nor orphan
secondaries.
On PC → Android inbox import, zone reconstruction is a prerequisite: the app
applies catalog identities, flattened aliases and profile-state first, then the
body-zone companion before sessions, equipment associations, AI drafts and
feedback. Thus a later companion error cannot leave a freshly catalogued
database with an accidental empty zone state ready to be republished. Zone
resolution remains exclusively by stable `exercise_id` and the durable alias
mapping; display names are never used.
The user does not need a separate manual export step before synchronization.
An active draft is never included in completed history, session detail or this

View file

@ -16,6 +16,7 @@ TRAINLOG_FORMAT_V1=FROZEN
TRAINLOG_AI_EXPORT_V1=PASS
TRAINLOG_AI_GDRIVE_SYNC_V1=PASS
TRAINLOG_AI_SESSION_DRAFT_V1=VALIDATION_PENDING
TRAINLOG_ANDROID_BODY_ZONE_REPOPULATION_FIX=PASS
DESKTOP_SCHEMA_V11=PASS
ANDROID_LOCAL_DATABASE_V10=PASS
@ -72,6 +73,7 @@ APP_SHELL_V1=IMPLEMENTED_AWAITING_VISUAL_REVIEW_2
DESKTOP_TESTS=52/52 PASS (normal); affected ASan/UBSan tests=6/6 PASS
ANDROID_BUILD=PASS
TRAINLOG_ANDROID_UI_REDESIGN_V1=PASS
HARDWARE_SYNC_VALIDATION=HISTORICAL_PASS
```

View file

@ -536,6 +536,12 @@ p PC -> Android: Drive AI draft midpoint -> AI-drafts companion -> profile
b bidirectional: complete inbound sequence -> Drive AI draft midpoint -> complete outbound sequence
```
Android consumes that PC publication by reconciling definitions, catalog,
flattened aliases and strict profile-state before body zones. Sessions,
equipment associations, AI drafts and feedback follow zone reconstruction, so
a later companion failure cannot expose a newly catalogued but accidentally
unclassified database as a valid outbound zone edit.
In both directions, definitions are reconciled before V2 artifacts that may
reference their IDs. A mode retains normal validation, transactions, conflict
reporting, and structured history for the work it performs.

View file

@ -791,7 +791,13 @@ explicit canonical-name conflict without writing a wrongly named target.
`DirectExchangeStorageTest` verifies the manifest permission and settings
action, refusal without an authorized backend, directory creation, real
temporary-file replacement and truncation, ten publications without suffixes,
and preservation of legacy Download and recovery fixtures.
and preservation of legacy Download and recovery fixtures. It also reconstructs
a truly fresh Android database from catalog, alias, profile-state and body-zone
companions, including an old zone `exercise_id` resolved through the durable
alias. A deliberately malformed downstream session companion proves zones are
already durable before the error; the repaired pass imports the one-sync AI
draft, and replay preserves three canonical identities and exactly seven zone
rows without duplicates.
`AiSessionDraftUiWiringTest` additionally requires the delete action to expose
the titled confirmation before mutation, verifies Cancel and Back dismissal,
then proves that only confirmation removes the pending proposal while retaining