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 # 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 - Added an Android confirmation dialog before deleting an imported AI session
draft. Cancel, Back and outside dismissal are non-mutating; only the draft. Cancel, Back and outside dismissal are non-mutating; only the
destructive confirmation invokes the existing deleted-tombstone operation. destructive confirmation invokes the existing deleted-tombstone operation.

View file

@ -233,7 +233,7 @@ class SessionDraftUiInstrumentedTest {
compose.onNodeWithTag("delete-set-1").performScrollTo().performClick() compose.onNodeWithTag("delete-set-1").performScrollTo().performClick()
compose.onNodeWithText("Supprimer la série 1 ?").assertIsDisplayed() compose.onNodeWithText("Supprimer la série 1 ?").assertIsDisplayed()
compose.onNodeWithTag("session-set-0-reps").assertTextEquals("4") 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.onNodeWithText("Ajouter une série").performScrollTo().performClick()
compose.onNodeWithTag("session-set-0-reps").performScrollTo().assertTextEquals("9") 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}") is ExerciseProfileStateImportResult.Conflict -> return CatalogInboxResult.Error("Conflit de profil : ${profile.exerciseId}")
ExerciseProfileStateImportResult.DatabaseError -> return CatalogInboxResult.Error("Erreur base locale profils.") ExerciseProfileStateImportResult.DatabaseError -> return CatalogInboxResult.Error("Erreur base locale profils.")
} }
/* Catalogue identities now exist and the strict second pass /* WHY: body zones are reconstruction prerequisites, not a
* has either installed or verified their causal state. */ * 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) } importPcSessions(directory)?.let { return CatalogInboxResult.Error(it) }
importPcEquipmentAssociations(directory)?.let { return CatalogInboxResult.Error(it) } importPcEquipmentAssociations(directory)?.let { return CatalogInboxResult.Error(it) }
importAiSessionDrafts(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) } importTrainingFeedback(directory)?.let { return CatalogInboxResult.Error(it) }
CatalogInboxResult.Imported( CatalogInboxResult.Imported(
imported = result.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 -> 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 } 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 }, { 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é.", { TrainlogAction("+ Ajouter une série", "Ajouter explicitement un fait réalisé.", {
replaceDraft(drafts, occurrenceIndex, draft.copy(sets = draft.sets + SessionSetDraft())) { drafts = it } replaceDraft(drafts, occurrenceIndex, draft.copy(sets = draft.sets + SessionSetDraft())) { drafts = it }
}, accent = colors.success) }, accent = colors.success)
} }
TrainlogIconAction(TrainlogIcons.DeleteOutline, "Supprimer cette occurrence", TrainlogDeleteButton("Supprimer cette occurrence",
{ pendingOccurrenceRemoval = occurrenceIndex }, { pendingOccurrenceRemoval = occurrenceIndex },
Modifier.testTag("remove-completed-occurrence-${draft.entryId}"), colors.error) Modifier.testTag("remove-completed-occurrence-${draft.entryId}"))
} }
} }
message?.let { TrainlogInfo(it, colors.error) } message?.let { TrainlogInfo(it, colors.error) }
@ -112,7 +112,8 @@ fun CompletedSessionCorrectionScreen(
TrainlogAction("Annuler", "Quitter sans modifier la base.", { onFinished(false) }, accent = colors.muted) TrainlogAction("Annuler", "Quitter sans modifier la base.", { onFinished(false) }, accent = colors.muted)
} }
pendingSetRemoval?.let { (occurrence, set) -> 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] val draft = drafts[occurrence]
replaceDraft(drafts, occurrence, draft.copy(sets = draft.sets.filterIndexed { index, _ -> index != set })) { drafts = it } replaceDraft(drafts, occurrence, draft.copy(sets = draft.sets.filterIndexed { index, _ -> index != set })) { drafts = it }
pendingSetRemoval = null pendingSetRemoval = null
@ -122,7 +123,8 @@ fun CompletedSessionCorrectionScreen(
val draft = drafts[occurrence] val draft = drafts[occurrence]
DestructiveConfirmationDialog("Supprimer cette 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.", "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 } drafts = drafts.filterIndexed { index, _ -> index != occurrence }
pendingOccurrenceRemoval = null 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.Row
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding 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.foundation.text.BasicText
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Text import androidx.compose.material3.Text
@ -18,6 +21,7 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.semantics.selected import androidx.compose.ui.semantics.selected
import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
@ -678,6 +682,17 @@ fun ExerciseCatalogueScreen(
} }
TrainlogFrame("Catalogue", active = exercises.isNotEmpty()) { TrainlogFrame("Catalogue", active = exercises.isNotEmpty()) {
if (exercises.isEmpty()) TrainlogInfo("Aucun exercice trouvé.") if (exercises.isEmpty()) TrainlogInfo("Aucun exercice trouvé.")
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 -> exercises.forEach { exercise ->
TrainlogAction(exercise.name, exerciseZoneSummary(repository, exercise), { TrainlogAction(exercise.name, exerciseZoneSummary(repository, exercise), {
state.selectedExerciseId = exercise.exerciseId state.selectedExerciseId = exercise.exerciseId
@ -686,6 +701,8 @@ fun ExerciseCatalogueScreen(
} }
} }
} }
}
}
} }
@Composable @Composable

View file

@ -10,6 +10,8 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height 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.offset
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size 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) TrainlogInfo("Dernier MAX · ${maximum.exerciseName} · $weight kg · ${maximum.startedAt.take(10)}", colors.warning)
} }
TrainlogFrame("Accès rapides", active = false) { TrainlogFrame("Accès rapides", active = false) {
TrainlogAction("Mensurations", "Ajouter ou consulter les relevés locaux.", onBody) Row(Modifier.fillMaxWidth().height(IntrinsicSize.Min).testTag("home-quick-actions-row"),
TrainlogAction("Synchronisation", "Consulter l'état connu et lancer une action explicite.", onSync) 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.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue 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 androidx.compose.ui.platform.LocalContext
import com.labfytools.trainlog.data.CreateEquipmentResult import com.labfytools.trainlog.data.CreateEquipmentResult
import com.labfytools.trainlog.data.CatalogInboxResult 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) else TrainlogAction("Reprendre", "${draft.exercises.size} exercice(s) · le brouillon durable est conservé.", onResume, accent = colors.success)
} }
TrainlogFrame("Préparer") { 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) Row(Modifier.fillMaxWidth().height(IntrinsicSize.Min).testTag("sessions-prepare-row"),
TrainlogAction( horizontalArrangement = Arrangement.spacedBy(10.dp)) {
"Brouillons", TrainlogActionTile("Nouvelle séance manuelle", "Créer ou reprendre", TrainlogIcons.Edit,
"$pendingAiDraftCount séance(s) préparée(s) · consulter les propositions importées depuis le PC.", onManual, Modifier.weight(1f).fillMaxHeight().testTag("sessions-manual-action"))
onDrafts, 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 is StartAiSessionDraftResult.Error -> message = result.message
} }
} }
TrainlogAction( TrainlogDeleteButton(
"Supprimer", contentDescription = "Supprimer ce brouillon",
"Conserver un tombstone pour empêcher sa réapparition lors d'un replay.", modifier = Modifier.testTag("delete-ai-draft-${draft.draftId}"),
onClick = { onClick = {
pendingDeletion = draft.draftId to (draft.title ?: "Proposition de séance") pendingDeletion = draft.draftId to (draft.title ?: "Proposition de séance")
}, },
accent = colors.error,
) )
} }
} }
@ -111,6 +122,7 @@ fun AiSessionDraftsScreen(
confirmLabel = "Supprimer", confirmLabel = "Supprimer",
onCancel = { pendingDeletion = null }, onCancel = { pendingDeletion = null },
dismissOnClickOutside = true, dismissOnClickOutside = true,
deleteContentDescription = "Supprimer ce brouillon",
) { ) {
/* CONTRACT: dialog confirmation is the sole UI path to the existing /* CONTRACT: dialog confirmation is the sole UI path to the existing
* tombstone mutation. Dismissal never reaches the repository. */ * 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.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag import androidx.compose.ui.platform.testTag
import androidx.compose.foundation.layout.fillMaxWidth
import com.labfytools.trainlog.data.TrainlogRepository import com.labfytools.trainlog.data.TrainlogRepository
import com.labfytools.trainlog.data.SaveFeedbackResult import com.labfytools.trainlog.data.SaveFeedbackResult
import com.labfytools.trainlog.data.exerciseFeedbackElapsedLabel import com.labfytools.trainlog.data.exerciseFeedbackElapsedLabel
@ -104,12 +105,8 @@ fun SessionDetailScreen(
"${detail.summary.exerciseCount} exercice(s)" "${detail.summary.exerciseCount} exercice(s)"
) )
TrainlogAction( TrainlogButton("Modifier la séance", onCorrect,
label = "Modifier la séance", Modifier.fillMaxWidth().testTag("edit-completed-session"))
description = "Corriger les faits enregistrés sans changer l'identité de la séance.",
accent = colors.accent,
onClick = onCorrect,
)
if (detail.summary.sessionType == SessionType.MAX_TEST) { if (detail.summary.sessionType == SessionType.MAX_TEST) {
TrainlogAction( TrainlogAction(
@ -154,10 +151,8 @@ fun SessionDetailScreen(
value = equipmentQuery, value = equipmentQuery,
onValueChange = { equipmentQuery = it }, onValueChange = { equipmentQuery = it },
) )
TrainlogIconAction( TrainlogDeleteButton(
icon = TrainlogIcons.DeleteOutline,
contentDescription = "Retirer la machine de cet exercice", contentDescription = "Retirer la machine de cet exercice",
accent = colors.warning,
modifier = Modifier.testTag("delete-completed-equipment-${exercise.entryId}"), modifier = Modifier.testTag("delete-completed-equipment-${exercise.entryId}"),
onClick = { onClick = {
pendingEquipmentChange = exercise.entryId to null pendingEquipmentChange = exercise.entryId to null
@ -228,12 +223,9 @@ fun SessionDetailScreen(
} }
}, onCancel = { feedbackEntryId = null }) }, onCancel = { feedbackEntryId = null })
} else { } else {
TrainlogAction( TrainlogButton("Ajouter un ressenti", { feedbackEntryId = exercise.entryId },
label = if (exercise.feedback.isEmpty()) "🎙 Ressenti" else "+ Ajouter un ressenti", Modifier.fillMaxWidth().testTag("add-exercise-feedback-${exercise.entryId}"),
description = "Ajouter une observation subjective à cette occurrence.", icon = TrainlogIcons.Mic)
accent = colors.accent,
onClick = { feedbackEntryId = exercise.entryId },
)
} }
} }
} }
@ -260,9 +252,9 @@ fun SessionDetailScreen(
is SaveFeedbackResult.DatabaseError -> result.message is SaveFeedbackResult.DatabaseError -> result.message
} }
}, onCancel = { addingFollowUp = false }) }, onCancel = { addingFollowUp = false })
} else TrainlogAction("+ Ajouter un enregistrement", } else TrainlogButton("Ajouter un enregistrement", { addingFollowUp = true },
"Ajouter une observation chronologique sans l’attribuer à un exercice.", Modifier.fillMaxWidth().testTag("add-session-follow-up"), icon = TrainlogIcons.Mic,
onClick = { addingFollowUp = true }, accent = colors.success) containerColor = colors.success)
} }
pendingEquipmentChange?.let { (entryId, equipmentId) -> pendingEquipmentChange?.let { (entryId, equipmentId) ->
DestructiveConfirmationDialog( 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.", 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", confirmLabel = if (equipmentId == null) "Retirer" else "Remplacer",
onCancel = { pendingEquipmentChange = null }, onCancel = { pendingEquipmentChange = null },
deleteContentDescription = if (equipmentId == null) {
"Retirer la machine de cet exercice"
} else null,
onConfirm = { onConfirm = {
if (repository.setCompletedSessionEquipment(detail.summary.sessionId,entryId,equipmentId)) { if (repository.setCompletedSessionEquipment(detail.summary.sessionId,entryId,equipmentId)) {
revision++;editingEntryId=null;equipmentQuery="";pendingEquipmentChange=null 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 = { TrainlogAction("Descendre", "Déplacer cet exercice après le suivant.", onClick = {
if (!busy) preview = SessionGeneratorPreviewController.move(current, index, 1) if (!busy) preview = SessionGeneratorPreviewController.move(current, index, 1)
}, accent = colors.muted) }, 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) 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) }) 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.clickable
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn 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.layout.padding
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
@ -378,10 +382,8 @@ fun SessionScreen(
} }
if (currentDraft.sourceSessionId == null) { if (currentDraft.sourceSessionId == null) {
TrainlogIconAction( TrainlogDeleteButton(
icon = TrainlogIcons.DeleteOutline,
contentDescription = "Supprimer cet exercice", contentDescription = "Supprimer cet exercice",
accent = colors.error,
modifier = Modifier.testTag("delete-draft-exercise-${draft.entryId}"), modifier = Modifier.testTag("delete-draft-exercise-${draft.entryId}"),
onClick = { onClick = {
pendingRemoval = index to draft pendingRemoval = index to draft
@ -459,33 +461,16 @@ fun SessionScreen(
} }
TrainlogFrame( TrainlogFrame(
title = "Exercices" title = "Actions",
) {
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",
active = active =
currentDraft.exercises.isNotEmpty(), currentDraft.exercises.isNotEmpty(),
) { ) {
TrainlogAction( Row(Modifier.fillMaxWidth().height(IntrinsicSize.Min).testTag("active-session-actions-row"),
label = horizontalArrangement = Arrangement.spacedBy(8.dp)) {
"Enregistrer la séance", TrainlogButton("Créer un nouvel exercice", onCreateExercise,
description = Modifier.weight(1f).fillMaxHeight().testTag("active-session-create-exercise"),
"${currentDraft.exercises.size} exercice(s) dans la séance.", containerColor = colors.surfaceAlt, maxLines = 2)
accent = TrainlogButton("Enregistrer la séance", onClick = {
colors.success,
onClick = {
when ( when (
val result = val result =
repository.finalizeActiveSessionDraft() repository.finalizeActiveSessionDraft()
@ -505,13 +490,13 @@ fun SessionScreen(
result.message 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( TrainlogDeleteButton(
icon = TrainlogIcons.DeleteOutline,
contentDescription = "Supprimer la séance en cours", contentDescription = "Supprimer la séance en cours",
accent = colors.error,
modifier = Modifier.testTag("delete-active-draft"), modifier = Modifier.testTag("delete-active-draft"),
onClick = { onClick = {
confirmingDiscard = true 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.", detail = "Cette action supprimera le brouillon, ses ${currentDraft.exercises.size} exercice(s) et toutes leurs données enregistrées.",
confirmLabel = "Abandonner", confirmLabel = "Abandonner",
onCancel = { confirmingDiscard = false }, onCancel = { confirmingDiscard = false },
deleteContentDescription = "Supprimer la séance en cours",
onConfirm = { onConfirm = {
when ( when (
val result = val result =
@ -578,6 +564,7 @@ fun SessionScreen(
"Cette action supprimera :\n- " + facts.joinToString("\n- "), "Cette action supprimera :\n- " + facts.joinToString("\n- "),
confirmLabel = "Supprimer", confirmLabel = "Supprimer",
onCancel = { pendingRemoval = null }, onCancel = { pendingRemoval = null },
deleteContentDescription = "Supprimer cet exercice",
onConfirm = { onConfirm = {
persistDraft(currentDraft.copy(exercises = currentDraft.exercises.filterIndexed { i, _ -> i != index }), persistDraft(currentDraft.copy(exercises = currentDraft.exercises.filterIndexed { i, _ -> i != index }),
"Exercice retiré de la séance.") "Exercice retiré de la séance.")
@ -1139,10 +1126,8 @@ private fun SessionExerciseForm(
) )
}, },
) )
TrainlogIconAction( TrainlogDeleteButton(
icon = TrainlogIcons.DeleteOutline,
contentDescription = "Supprimer cette série", contentDescription = "Supprimer cette série",
accent = colors.error,
modifier = Modifier.testTag("delete-set-${index + 1}"), modifier = Modifier.testTag("delete-set-${index + 1}"),
onClick = { onClick = {
pendingSetRemoval = index pendingSetRemoval = index
@ -1387,6 +1372,7 @@ private fun SessionExerciseForm(
detail = "Ses répétitions, sa durée et sa charge saisies seront supprimées.", detail = "Ses répétitions, sa durée et sa charge saisies seront supprimées.",
confirmLabel = "Supprimer", confirmLabel = "Supprimer",
onCancel = { pendingSetRemoval = null }, onCancel = { pendingSetRemoval = null },
deleteContentDescription = "Supprimer cette série",
onConfirm = { onConfirm = {
val updated=setRows.filterIndexed { rowIndex, _ -> rowIndex != index } val updated=setRows.filterIndexed { rowIndex, _ -> rowIndex != index }
setRows=updated;repsText=encodeRawReps(updated);weightText=encodeRawWeights(updated);error=null 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.padding
import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.BasicText import androidx.compose.foundation.text.BasicText
import androidx.compose.foundation.text.BasicTextField 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 @Composable
fun TrainlogPrimaryAction(label: String, description: String, onClick: () -> Unit) { fun TrainlogPrimaryAction(label: String, description: String, onClick: () -> Unit) {
val colors = LocalTrainlogColors.current 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. */ /** Compact horizontally scrollable choices; selection is textual and colored. */
@Composable @Composable
fun TrainlogChoiceChips( fun TrainlogChoiceChips(
@ -370,7 +444,7 @@ fun TrainlogInfo(
} }
/** Shared destructive commit gate. CONTRACT: dismissal and Back are safe, /** 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. */ * the destructive callback. */
@Composable @Composable
fun DestructiveConfirmationDialog( fun DestructiveConfirmationDialog(
@ -379,6 +453,7 @@ fun DestructiveConfirmationDialog(
confirmLabel: String, confirmLabel: String,
onCancel: () -> Unit, onCancel: () -> Unit,
dismissOnClickOutside: Boolean = false, dismissOnClickOutside: Boolean = false,
deleteContentDescription: String? = null,
onConfirm: () -> Unit, onConfirm: () -> Unit,
) { ) {
val colors = LocalTrainlogColors.current val colors = LocalTrainlogColors.current
@ -388,10 +463,18 @@ fun DestructiveConfirmationDialog(
text = { Text(detail) }, text = { Text(detail) },
dismissButton = { TextButton(onClick = onCancel) { Text("Annuler") } }, dismissButton = { TextButton(onClick = onCancel) { Text("Annuler") } },
confirmButton = { confirmButton = {
if (deleteContentDescription != null) {
TrainlogDeleteButton(
deleteContentDescription,
onConfirm,
Modifier.testTag("confirm-destructive-delete"),
)
} else {
TextButton( TextButton(
onClick = onConfirm, onClick = onConfirm,
colors = ButtonDefaults.textButtonColors(contentColor = colors.error), colors = ButtonDefaults.textButtonColors(contentColor = colors.error),
) { Text(confirmLabel) } ) { Text(confirmLabel) }
}
}, },
properties = DialogProperties( properties = DialogProperties(
dismissOnBackPress = true, 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.graphics.vector.path
import androidx.compose.ui.unit.dp 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 { 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 { val Mic: ImageVector by lazy {
ImageVector.Builder( ImageVector.Builder(
name = "Mic", name = "Mic",

View file

@ -3,6 +3,7 @@ package com.labfytools.trainlog.data
import android.Manifest import android.Manifest
import android.content.Context import android.content.Context
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.database.sqlite.SQLiteDatabase
import android.provider.Settings import android.provider.Settings
import androidx.test.core.app.ApplicationProvider import androidx.test.core.app.ApplicationProvider
import java.io.File 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`() { @Test fun `fresh catalog row adopts only initial resolved machine identity`() {
val databaseName = "direct-profile-${UUID.randomUUID()}.db" val databaseName = "direct-profile-${UUID.randomUUID()}.db"
val id = "ex_33333333-3333-4333-8333-333333333333" 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.assertIsDisplayed
import androidx.compose.ui.test.junit4.createComposeRule import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithText 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.performClick
import androidx.compose.ui.test.performSemanticsAction
import androidx.compose.ui.semantics.SemanticsActions
import androidx.compose.ui.input.key.Key import androidx.compose.ui.input.key.Key
import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.test.performKeyInput import androidx.compose.ui.test.performKeyInput
@ -15,6 +19,7 @@ import androidx.compose.ui.test.performTouchInput
import androidx.compose.ui.test.click import androidx.compose.ui.test.click
import androidx.test.core.app.ApplicationProvider import androidx.test.core.app.ApplicationProvider
import com.labfytools.trainlog.data.ActiveDraftLoadResult import com.labfytools.trainlog.data.ActiveDraftLoadResult
import com.labfytools.trainlog.data.ActiveDraftMutationResult
import com.labfytools.trainlog.data.AiSessionDraftImportResult import com.labfytools.trainlog.data.AiSessionDraftImportResult
import com.labfytools.trainlog.data.CreateExerciseResult import com.labfytools.trainlog.data.CreateExerciseResult
import com.labfytools.trainlog.data.TrainlogRepository import com.labfytools.trainlog.data.TrainlogRepository
@ -70,20 +75,56 @@ class AiSessionDraftUiWiringTest {
@Test @Test
fun sessionsHubShowsPendingCountAndPreservesManualAndHistoryActions() { fun sessionsHubShowsPendingCountAndPreservesManualAndHistoryActions() {
var manualCalls = 0 var manualCalls = 0
var draftCalls = 0
var historyCalls = 0 var historyCalls = 0
compose.setContent { TrainlogTheme { 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("2 séance(s) préparée(s)", substring = true).assertIsDisplayed()
compose.onNodeWithText("Nouvelle séance manuelle").performClick() compose.onNodeWithText("Nouvelle séance manuelle").performClick()
compose.onNodeWithText("Brouillons").performClick()
compose.onNodeWithText("Séances effectuées").performClick() compose.onNodeWithText("Séances effectuées").performClick()
compose.runOnIdle { compose.runOnIdle {
assertEquals(1, manualCalls) assertEquals(1, manualCalls)
assertEquals(1, draftCalls)
assertEquals(1, historyCalls) 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 @Test
fun draftScreenShowsDosBicepsAndTargetDetails() { fun draftScreenShowsDosBicepsAndTargetDetails() {
importDraft() importDraft()
@ -158,9 +199,11 @@ class AiSessionDraftUiWiringTest {
AiSessionDraftsScreen(repository, externalRevision = 0, onStarted = {}) 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.onNodeWithText("Supprimer ce brouillon ?").assertIsDisplayed()
compose.onNodeWithTag("confirm-destructive-delete").assertIsDisplayed()
compose.onNodeWithText("Le brouillon « Dos + biceps » sera supprimé.", substring = true) compose.onNodeWithText("Le brouillon « Dos + biceps » sera supprimé.", substring = true)
.assertIsDisplayed() .assertIsDisplayed()
compose.runOnIdle { assertEquals(1, repository.listAiSessionDrafts().size) } compose.runOnIdle { assertEquals(1, repository.listAiSessionDrafts().size) }
@ -173,7 +216,7 @@ class AiSessionDraftUiWiringTest {
AiSessionDraftsScreen(repository, externalRevision = 0, onStarted = {}) AiSessionDraftsScreen(repository, externalRevision = 0, onStarted = {})
} } } }
compose.onNodeWithText("Supprimer").performClick() compose.onNodeWithContentDescription("Supprimer ce brouillon").performClick()
compose.onNodeWithText("Annuler").performClick() compose.onNodeWithText("Annuler").performClick()
compose.onNodeWithText("Dos + biceps").assertIsDisplayed() compose.onNodeWithText("Dos + biceps").assertIsDisplayed()
@ -187,7 +230,7 @@ class AiSessionDraftUiWiringTest {
AiSessionDraftsScreen(repository, externalRevision = 0, onStarted = {}) AiSessionDraftsScreen(repository, externalRevision = 0, onStarted = {})
} } } }
compose.onNodeWithText("Supprimer").performClick() compose.onNodeWithContentDescription("Supprimer ce brouillon").performClick()
compose.onNodeWithText("Supprimer ce brouillon ?").performKeyInput { compose.onNodeWithText("Supprimer ce brouillon ?").performKeyInput {
pressKey(Key.Back) pressKey(Key.Back)
} }
@ -203,7 +246,7 @@ class AiSessionDraftUiWiringTest {
AiSessionDraftsScreen(repository, externalRevision = 0, onStarted = {}) AiSessionDraftsScreen(repository, externalRevision = 0, onStarted = {})
} } } }
compose.onNodeWithText("Supprimer").performClick() compose.onNodeWithContentDescription("Supprimer ce brouillon").performClick()
compose.onNodeWithText("Supprimer ce brouillon ?").performTouchInput { compose.onNodeWithText("Supprimer ce brouillon ?").performTouchInput {
click(Offset(-40f, -40f)) 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.onNodeWithText("Supprimer ce brouillon ?").assertIsDisplayed()
compose.onAllNodesWithText("Supprimer")[1].performClick() compose.onNodeWithTag("confirm-destructive-delete").performClick()
compose.onNodeWithText("Aucun brouillon importé.").assertIsDisplayed() compose.onNodeWithText("Aucun brouillon importé.").assertIsDisplayed()
compose.runOnIdle { 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 second navigation state. The drawer itself scrolls when vertical space is
limited. 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 ## STATS_V1
The Statistics landing page defaults to 30 days and is a Material 3 dashboard using the existing 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 local relations. The unclassified state contains neither a primary nor orphan
secondaries. 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. 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 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_EXPORT_V1=PASS
TRAINLOG_AI_GDRIVE_SYNC_V1=PASS TRAINLOG_AI_GDRIVE_SYNC_V1=PASS
TRAINLOG_AI_SESSION_DRAFT_V1=VALIDATION_PENDING TRAINLOG_AI_SESSION_DRAFT_V1=VALIDATION_PENDING
TRAINLOG_ANDROID_BODY_ZONE_REPOPULATION_FIX=PASS
DESKTOP_SCHEMA_V11=PASS DESKTOP_SCHEMA_V11=PASS
ANDROID_LOCAL_DATABASE_V10=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 DESKTOP_TESTS=52/52 PASS (normal); affected ASan/UBSan tests=6/6 PASS
ANDROID_BUILD=PASS ANDROID_BUILD=PASS
TRAINLOG_ANDROID_UI_REDESIGN_V1=PASS
HARDWARE_SYNC_VALIDATION=HISTORICAL_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 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 In both directions, definitions are reconciled before V2 artifacts that may
reference their IDs. A mode retains normal validation, transactions, conflict reference their IDs. A mode retains normal validation, transactions, conflict
reporting, and structured history for the work it performs. 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 `DirectExchangeStorageTest` verifies the manifest permission and settings
action, refusal without an authorized backend, directory creation, real action, refusal without an authorized backend, directory creation, real
temporary-file replacement and truncation, ten publications without suffixes, 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 `AiSessionDraftUiWiringTest` additionally requires the delete action to expose
the titled confirmation before mutation, verifies Cancel and Back dismissal, the titled confirmation before mutation, verifies Cancel and Back dismissal,
then proves that only confirmation removes the pending proposal while retaining then proves that only confirmation removes the pending proposal while retaining