diff --git a/CHANGELOG.md b/CHANGELOG.md index b99571f..dcd94a2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/android/app/src/androidTest/java/com/labfytools/trainlog/ui/SessionDraftUiInstrumentedTest.kt b/android/app/src/androidTest/java/com/labfytools/trainlog/ui/SessionDraftUiInstrumentedTest.kt index dbcb790..8779c17 100644 --- a/android/app/src/androidTest/java/com/labfytools/trainlog/ui/SessionDraftUiInstrumentedTest.kt +++ b/android/app/src/androidTest/java/com/labfytools/trainlog/ui/SessionDraftUiInstrumentedTest.kt @@ -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") diff --git a/android/app/src/main/java/com/labfytools/trainlog/data/SyncCatalogInbox.kt b/android/app/src/main/java/com/labfytools/trainlog/data/SyncCatalogInbox.kt index 85f8949..621351c 100644 --- a/android/app/src/main/java/com/labfytools/trainlog/data/SyncCatalogInbox.kt +++ b/android/app/src/main/java/com/labfytools/trainlog/data/SyncCatalogInbox.kt @@ -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, diff --git a/android/app/src/main/java/com/labfytools/trainlog/ui/CompletedSessionCorrectionScreen.kt b/android/app/src/main/java/com/labfytools/trainlog/ui/CompletedSessionCorrectionScreen.kt index e00ba8b..4675d95 100644 --- a/android/app/src/main/java/com/labfytools/trainlog/ui/CompletedSessionCorrectionScreen.kt +++ b/android/app/src/main/java/com/labfytools/trainlog/ui/CompletedSessionCorrectionScreen.kt @@ -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 } diff --git a/android/app/src/main/java/com/labfytools/trainlog/ui/ExerciseScreen.kt b/android/app/src/main/java/com/labfytools/trainlog/ui/ExerciseScreen.kt index 778fb21..ec33934 100644 --- a/android/app/src/main/java/com/labfytools/trainlog/ui/ExerciseScreen.kt +++ b/android/app/src/main/java/com/labfytools/trainlog/ui/ExerciseScreen.kt @@ -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) + }) + } + } } } } diff --git a/android/app/src/main/java/com/labfytools/trainlog/ui/HomeScreen.kt b/android/app/src/main/java/com/labfytools/trainlog/ui/HomeScreen.kt index 0d52f28..844a1db 100644 --- a/android/app/src/main/java/com/labfytools/trainlog/ui/HomeScreen.kt +++ b/android/app/src/main/java/com/labfytools/trainlog/ui/HomeScreen.kt @@ -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")) + } } } } diff --git a/android/app/src/main/java/com/labfytools/trainlog/ui/SectionScreens.kt b/android/app/src/main/java/com/labfytools/trainlog/ui/SectionScreens.kt index 65f8d28..c8b49d6 100644 --- a/android/app/src/main/java/com/labfytools/trainlog/ui/SectionScreens.kt +++ b/android/app/src/main/java/com/labfytools/trainlog/ui/SectionScreens.kt @@ -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. */ diff --git a/android/app/src/main/java/com/labfytools/trainlog/ui/SessionDetailScreen.kt b/android/app/src/main/java/com/labfytools/trainlog/ui/SessionDetailScreen.kt index 809ddfc..873a0a8 100644 --- a/android/app/src/main/java/com/labfytools/trainlog/ui/SessionDetailScreen.kt +++ b/android/app/src/main/java/com/labfytools/trainlog/ui/SessionDetailScreen.kt @@ -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 diff --git a/android/app/src/main/java/com/labfytools/trainlog/ui/SessionGeneratorScreen.kt b/android/app/src/main/java/com/labfytools/trainlog/ui/SessionGeneratorScreen.kt index 306e2b1..b4581e7 100644 --- a/android/app/src/main/java/com/labfytools/trainlog/ui/SessionGeneratorScreen.kt +++ b/android/app/src/main/java/com/labfytools/trainlog/ui/SessionGeneratorScreen.kt @@ -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) }) diff --git a/android/app/src/main/java/com/labfytools/trainlog/ui/SessionScreen.kt b/android/app/src/main/java/com/labfytools/trainlog/ui/SessionScreen.kt index 9a4a486..0cf0d5f 100644 --- a/android/app/src/main/java/com/labfytools/trainlog/ui/SessionScreen.kt +++ b/android/app/src/main/java/com/labfytools/trainlog/ui/SessionScreen.kt @@ -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 diff --git a/android/app/src/main/java/com/labfytools/trainlog/ui/TrainlogComponents.kt b/android/app/src/main/java/com/labfytools/trainlog/ui/TrainlogComponents.kt index e126f61..26cb5de 100644 --- a/android/app/src/main/java/com/labfytools/trainlog/ui/TrainlogComponents.kt +++ b/android/app/src/main/java/com/labfytools/trainlog/ui/TrainlogComponents.kt @@ -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, diff --git a/android/app/src/main/java/com/labfytools/trainlog/ui/TrainlogIcons.kt b/android/app/src/main/java/com/labfytools/trainlog/ui/TrainlogIcons.kt index 4ceec6c..b9f3c02 100644 --- a/android/app/src/main/java/com/labfytools/trainlog/ui/TrainlogIcons.kt +++ b/android/app/src/main/java/com/labfytools/trainlog/ui/TrainlogIcons.kt @@ -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", diff --git a/android/app/src/test/java/com/labfytools/trainlog/data/DirectExchangeStorageTest.kt b/android/app/src/test/java/com/labfytools/trainlog/data/DirectExchangeStorageTest.kt index 05770a7..c858403 100644 --- a/android/app/src/test/java/com/labfytools/trainlog/data/DirectExchangeStorageTest.kt +++ b/android/app/src/test/java/com/labfytools/trainlog/data/DirectExchangeStorageTest.kt @@ -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 = 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" diff --git a/android/app/src/test/java/com/labfytools/trainlog/ui/AiSessionDraftUiWiringTest.kt b/android/app/src/test/java/com/labfytools/trainlog/ui/AiSessionDraftUiWiringTest.kt index 743be35..7731f79 100644 --- a/android/app/src/test/java/com/labfytools/trainlog/ui/AiSessionDraftUiWiringTest.kt +++ b/android/app/src/test/java/com/labfytools/trainlog/ui/AiSessionDraftUiWiringTest.kt @@ -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 { diff --git a/android/app/src/test/java/com/labfytools/trainlog/ui/AndroidUiRedesignTest.kt b/android/app/src/test/java/com/labfytools/trainlog/ui/AndroidUiRedesignTest.kt new file mode 100644 index 0000000..85bb05c --- /dev/null +++ b/android/app/src/test/java/com/labfytools/trainlog/ui/AndroidUiRedesignTest.kt @@ -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) + } + } +} diff --git a/docs/android.md b/docs/android.md index 4fef8ed..d991bc8 100644 --- a/docs/android.md +++ b/docs/android.md @@ -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 diff --git a/docs/current_state.md b/docs/current_state.md index a247a42..c6ea813 100644 --- a/docs/current_state.md +++ b/docs/current_state.md @@ -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 ``` diff --git a/docs/sync_exchange.md b/docs/sync_exchange.md index df2b44b..ed04fd5 100644 --- a/docs/sync_exchange.md +++ b/docs/sync_exchange.md @@ -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. diff --git a/docs/tests.md b/docs/tests.md index 1481cea..4cd817d 100644 --- a/docs/tests.md +++ b/docs/tests.md @@ -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