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