feat(trainlog): complete app shell and exercise merge

This commit is contained in:
fy59 2026-09-11 07:21:54 +02:00
parent 7cb49964e3
commit 1f027743f2
101 changed files with 14754 additions and 12170 deletions

View file

@ -164,7 +164,8 @@ It is not the canonical analytics store.
The Android UI is driven by exercise metadata, never by exercise-name
heuristics.
Android local SQLite schema v9 owns exactly one durable active-session draft.
Android local SQLite schema v12 retains the exactly-one durable active-session
draft introduced in v4.
Every meaningful draft/form mutation is persisted by the repository. Back,
backgrounding and process death never delete the draft. Home offers explicit
resume; whole-draft discard requires confirmation. Final completed-session
@ -174,8 +175,10 @@ an explicit, non-destructive migration for future Android schema changes.
Android schema v11 additionally stores optional planning metadata separately
from actual occurrence data after the additive v10 -> v11 migration. Existing
rows retain `load_mode=none`, zero rest and NULL targets. Desktop remains schema
v11. The active completed-session exchange is the separate strict
rows retain `load_mode=none`, zero rest and NULL targets. Android v12 adds the
durable flattened exercise-alias mapping used to resolve retired creator IDs.
Desktop schema v12 owns the same alias compatibility boundary. The active
completed-session exchange is the separate strict
`trainlog-mobile-export` V3; V1/V2 remain readable and `TRAINLOG_FORMAT_V1`
remains unchanged.
@ -227,8 +230,9 @@ Desktop SQLite schema is versioned with:
PRAGMA user_version;
```
The current desktop schema is v9. Schema v9 adds an occurrence-owned explicit
maximum result without changing `TRAINLOG_FORMAT_V1`.
The current desktop schema is v12. Schema v9 added an occurrence-owned explicit
maximum result; v10/v11 added body-zone and planning metadata, and v12 adds
durable flattened exercise aliases. None changes `TRAINLOG_FORMAT_V1`.
Every incompatible schema evolution requires an explicit migration and
regression coverage.

View file

@ -1,5 +1,14 @@
# Changelog
- Added `PERCENT_MAX_INPUT_V1` as a transient user calculator in TUI planning
and both generator previews: exact exercise/equipment external-load context,
integer 1..100, `MAX × percentage / 100`, no recommendation, and only the
resulting target kg persisted. Automatic generator policy V1 is unchanged.
- Repaired TUI `/` through the stable action registry, deduplicated actions by
stable ID, strengthened Lavender plus textual selection states, compacted
Android generator choices into localized chips, and made generator duration,
shortfall, warm-up and cool-down limitations explicit.
All notable changes to Trainlog are documented here.
Detailed implementation chronology remains available in Git history and
@ -9,6 +18,22 @@ Detailed implementation chronology remains available in Git history and
### Added
- `APP_SHELL_V1=IMPLEMENTED_AWAITING_VISUAL_REVIEW_2`: a shared seven-root
application shell—Accueil, Séances, Exercices, Équipements, Statistiques,
Synchronisation and Paramètres—on the Notcurses TUI and Android Material 3
drawer. Séances now contains the existing generator, current/manual session
entry and completed history; existing body and MAX views are reached from
Statistiques. The TUI adds its run-scoped multi-plane shell, one event loop,
stable-ID list/focus restoration, bounded UTF-8 search/forms, F6 Navigation,
F7 shared actions, compact/sidebar thresholds, and explicit transient leave
guards. Android adds typed controller-owned routes, local vectors, 48 dp
actions and guarded navigation. The change adds no statistics, schema,
synchronization artifact or exercise-domain semantics. Automated validation
passed. A second automated repair review covered root search/action dispatch,
F6/F7 focus/selection restoration, shell hubs/catalogues and Android compact
presentation/latest-MAX semantics; human visual/accessibility review remains
pending.
- `SESSION_GENERATOR_V1=PASS`: one frozen shared policy; deterministic bounded previews for
11 BODY ZONES and four goals; explicit incomplete coverage and recorded-dose
recency; observed exact-equipment 28-day load anchors without MAX-derived
@ -96,8 +121,10 @@ Detailed implementation chronology remains available in Git history and
- Android `EXERCISE_EDIT_V1`: visible catalog editing, stable-ID name rename,
explicit invalid/conflict/profile/database results, and profile locking once
completed history or an active draft references the exercise;
- `ANDROID_BANNER_PARITY_V1`: one Android `◆ TRAINLOG ◆` header component
matching the compact Notcurses banner's accent and muted context rhythm;
- `ANDROID_BANNER_PARITY_V1`: the earlier Android `◆ TRAINLOG ◆` header
component matched the compact Notcurses banner's accent and muted context
rhythm. APP_SHELL_V1 supersedes that per-page presentation with the fixed
Material 3 `AndroidAppShell` top bar;
- one durable Android active-session draft with Home resume, raw form restore,
confirmed discard and draft-only exercise removal;

View file

@ -43,13 +43,23 @@ BODY_ZONE_SYNC_V1=PASS
BODY_ZONES_DESKTOP_REAL_MIGRATION=PASS
BODY_ZONES_ANDROID_DEVICE_VALIDATION=PASS
DESKTOP_TESTS=45/45 PASS (latest validated checkpoint)
DESKTOP_TESTS=47/47 PASS (latest validated checkpoint)
ANDROID_BUILD=PASS
TRAINING_KNOWLEDGE_V1=PASS
SESSION_GENERATOR_V1=PASS
APP_SHELL_V1=IMPLEMENTED_AWAITING_VISUAL_REVIEW_2
```
`APP_SHELL_V1` provides the shared seven-section application shell on both
platforms: **Accueil**, **Séances**, **Exercices**, **Équipements**,
**Statistiques**, **Synchronisation**, and **Paramètres**. Completed history
and the existing generator now live under **Séances**; existing body and MAX
views live under **Statistiques**. It records no new statistics, schema, or
synchronization protocol. Automated review repairs are recorded, but the
status remains awaiting human visual/accessibility review; see
[the APP_SHELL_V1 review record](docs/reviews/app_shell_v1.md).
`TRAINING_KNOWLEDGE_V1` has passed its bounded scientific review, independent
temporal delta review, final engineering audit, repair verification, and final
executable validation. Its C and
@ -195,11 +205,13 @@ export or desktop synchronization as completed sessions.
Schema migrations are additive and preserve existing capture data. See
[Android behavior](docs/android.md) and [validation](docs/tests.md).
The current Android schema is v11. Its additive v4 -> v10 chain adds the shared
The current Android schema is v12. Its additive v4 -> v12 chain adds the shared
equipment catalogue, per-occurrence equipment links, durable occurrence
identities, custom-equipment definition support, explicit MAX results and
stable-source Test max resumption, then direct primary/secondary body-zone
relations, without recreating completed history or discarding the active draft.
relations and occurrence planning, without recreating completed history or
discarding the active draft. The additive v11 -> v12 migration stores flattened
exercise aliases used to resolve retired IDs during synchronization.
Exercises can be renamed in place from Android. The `ex_<uuid-v4>` identity is
unchanged; completed history, an active draft, and synchronization therefore
@ -222,14 +234,14 @@ JAVA_HOME=/usr/lib/jvm/java-17-openjdk \
## Android-triggered synchronization
Launch the desktop TUI from a built checkout with:
Launch the desktop TUI from this built checkout with:
```bash
trainlog
cd /home/fy59/Documents/trainlog
./build/tui/trainlog
```
The usual user command resolves to `build/tui/trainlog` in this checkout. The
desktop database is `$XDG_DATA_HOME/trainlog/trainlog.db`, or
The desktop database is `$XDG_DATA_HOME/trainlog/trainlog.db`, or
`~/.local/share/trainlog/trainlog.db` when `XDG_DATA_HOME` is unset.
Build the desktop first, then install the user service:

View file

@ -59,6 +59,7 @@ dependencies {
implementation(composeBom)
androidTestImplementation(composeBom)
testImplementation(composeBom)
implementation(
"androidx.activity:activity-compose:1.13.0"
@ -68,6 +69,8 @@ dependencies {
"androidx.compose.foundation:foundation"
)
implementation("androidx.compose.material3:material3")
implementation(
"androidx.compose.ui:ui"
)
@ -81,10 +84,12 @@ dependencies {
debugImplementation(
"androidx.compose.ui:ui-tooling"
)
debugImplementation("androidx.compose.ui:ui-test-manifest")
testImplementation("junit:junit:4.13.2")
testImplementation("androidx.test:core:1.7.0")
testImplementation("org.robolectric:robolectric:4.16.1")
testImplementation("androidx.compose.ui:ui-test-junit4")
androidTestImplementation("androidx.test.ext:junit:1.3.0")
androidTestImplementation("androidx.test:runner:1.7.0")

View file

@ -77,6 +77,8 @@ class DraftUiTestActivity : ComponentActivity() {
?: (loaded as?
ActiveDraftLoadResult.Loaded)
?.warning,
latestSession = null,
latestMaximum = null,
onSession = {
when (
val result =
@ -93,25 +95,8 @@ class DraftUiTestActivity : ComponentActivity() {
}
}
},
onDiscardDraft = {
when (
val result =
repository
.discardActiveSessionDraft()
) {
ActiveDraftMutationResult.Saved -> {
error = null
revision += 1
}
is ActiveDraftMutationResult.Error -> {
error = result.message
}
}
},
onExercise = {},
onBody = {},
onHistory = {},
onOpenLatestSession = {},
onSync = {},
onGenerateSession = {},
)

View file

@ -3,14 +3,17 @@ package com.labfytools.trainlog
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.viewModels
import com.labfytools.trainlog.data.TrainlogRepository
import com.labfytools.trainlog.data.SyncExporter
import com.labfytools.trainlog.data.SyncCatalogInbox
import com.labfytools.trainlog.data.SyncRequestOutbox
import com.labfytools.trainlog.ui.TrainlogApp
import com.labfytools.trainlog.ui.TrainlogAppState
import com.labfytools.trainlog.ui.theme.TrainlogTheme
class MainActivity : ComponentActivity() {
private val appState by viewModels<TrainlogAppState>()
override fun onCreate(
savedInstanceState: Bundle?
) {
@ -46,6 +49,7 @@ class MainActivity : ComponentActivity() {
inbox = inbox,
requestOutbox =
requestOutbox,
appState = appState,
)
}
}

View file

@ -0,0 +1,24 @@
package com.labfytools.trainlog.data
enum class GeneratorLoadChoice { AUTOMATIC, PERCENT_MAX, NONE }
/** Pure user-directed arithmetic; this is not a recommendation engine. */
object PercentMaxCalculator {
fun calculate(
maximum: ExplicitMaxContext,
equipmentId: String,
percent: Int,
): Double? {
/* WHY: display names and similar machine families cannot establish a
* transferable MAX. CONTRACT: exact equipment identity plus external
* resistance is required; assistance is deliberately unavailable. */
if (percent !in 1..100 || maximum.equipmentId != equipmentId ||
maximum.loadSemantics != EquipmentLoadSemantics.EXTERNAL ||
!maximum.maxWeightKg.isFinite() || maximum.maxWeightKg <= 0.0
) return null
val target = maximum.maxWeightKg * percent.toDouble() / 100.0
/* INVARIANT: only the returned kg value may enter a SessionExercisePlan;
* percentage and MAX provenance remain transient preview state. */
return target.takeIf { it.isFinite() && it > 0.0 }
}
}

View file

@ -131,6 +131,8 @@ class SyncCatalogInbox(
val definitionsError = importPcEquipmentDefinitions(directory)
if (definitionsError != null) return CatalogInboxResult.Error(definitionsError)
val aliasesError = importExerciseAliases(directory)
if (aliasesError != null) return CatalogInboxResult.Error(aliasesError)
when (
val result =
@ -217,6 +219,25 @@ class SyncCatalogInbox(
}
}
private fun importExerciseAliases(directory: DocumentFile): String? {
val file = directory.findFile("trainlog-exercise-aliases-v1.json") ?: return null
return try {
val json = appContext.contentResolver.openInputStream(file.uri)
?.bufferedReader(Charsets.UTF_8)?.use { it.readText() }
?: return "Lecture des alias exercice impossible."
when (val result = repository.applyExerciseAliasesJson(json)) {
is ExerciseAliasImportResult.Applied -> null
is ExerciseAliasImportResult.Invalid -> result.message
is ExerciseAliasImportResult.Conflict ->
"Conflit d'alias exercice : ${result.sourceExerciseId}"
ExerciseAliasImportResult.DatabaseError ->
"Erreur base locale alias exercice."
}
} catch (error: Exception) {
error.message ?: "Import des alias exercice impossible."
}
}
private fun importPcEquipmentDefinitions(directory: DocumentFile): String? {
val file = directory.findFile("trainlog-pc-equipment-definitions-v1.json") ?: return null
return try {

View file

@ -47,6 +47,7 @@ class SyncExporter(
val mobileJson: String
val associationsJson: String
val bodyZonesJson: String
val aliasesJson: String
try {
definitionsJson = repository.buildEquipmentDefinitionsJson()
/* V3 is the authoritative mobile session exchange. V1/V2 remain
@ -54,6 +55,7 @@ class SyncExporter(
mobileJson = repository.buildMobileExportV3Json()
associationsJson = repository.buildEquipmentAssociationsJson()
bodyZonesJson = repository.buildExerciseBodyZonesJson()
aliasesJson = repository.buildExerciseAliasesJson()
} catch (error: Exception) {
return SyncExportResult.Error(
error.message ?: "Préparation de l'export impossible.",
@ -192,6 +194,12 @@ class SyncExporter(
val bodyZonesError = writeBodyZones(bodyZonesJson)
if (bodyZonesError != null) return SyncExportResult.Error(bodyZonesError)
val aliasesError = writeJsonCompanion(
"trainlog-exercise-aliases-v1.json",
aliasesJson,
"alias exercice",
)
if (aliasesError != null) return SyncExportResult.Error(aliasesError)
/* CONTRACT: publication, not JSON construction, establishes the
* common sync ancestor. Applying the exact local snapshot can only
* record equal baselines; the strict reconciler never unions zones. */
@ -294,6 +302,32 @@ class SyncExporter(
}
}
private fun writeJsonCompanion(name: String, json: String, label: String): String? {
val resolver = appContext.contentResolver
val collection = MediaStore.Downloads.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY)
val relativePath = Environment.DIRECTORY_DOWNLOADS + "/Trainlog/"
val existing = findExisting(collection, name, relativePath)
val created = existing == null
val uri = existing ?: resolver.insert(collection, ContentValues().apply {
put(MediaStore.MediaColumns.DISPLAY_NAME, name)
put(MediaStore.MediaColumns.MIME_TYPE, "application/json")
put(MediaStore.MediaColumns.RELATIVE_PATH, relativePath)
put(MediaStore.MediaColumns.IS_PENDING, 1)
}) ?: return "Création du compagnon $label impossible."
return try {
resolver.openOutputStream(uri, "wt")?.use {
it.write(json.toByteArray(Charsets.UTF_8)); it.flush()
} ?: return "Écriture du compagnon $label impossible."
if (created) resolver.update(uri, ContentValues().apply {
put(MediaStore.MediaColumns.IS_PENDING, 0)
}, null, null)
null
} catch (error: Exception) {
if (created) resolver.delete(uri, null, null)
error.message ?: "Export du compagnon $label impossible."
}
}
/** One directional-neutral companion is used by both peers. */
private fun writeBodyZones(json: String): String? {
val resolver = appContext.contentResolver

View file

@ -111,6 +111,13 @@ sealed interface ExerciseBodyZoneImportResult {
data object DatabaseError : ExerciseBodyZoneImportResult
}
sealed interface ExerciseAliasImportResult {
data class Applied(val added: Int, val skipped: Int) : ExerciseAliasImportResult
data class Invalid(val message: String) : ExerciseAliasImportResult
data class Conflict(val sourceExerciseId: String) : ExerciseAliasImportResult
data object DatabaseError : ExerciseAliasImportResult
}
sealed interface MobileSessionImportResult {
/** CONTRACT: counters describe persistent mutations, not artifact size. */
data class Applied(
@ -252,6 +259,15 @@ data class TrainingExerciseContext(
val recentPerformance: ExerciseOccurrencePage,
)
sealed interface ManualPercentMaxResult {
data class Available(
val maxWeightKg: Double,
val maxStartedAt: String,
val targetWeightKg: Double,
) : ManualPercentMaxResult
data class Unavailable(val message: String) : ManualPercentMaxResult
}
class TrainlogRepository(
context: Context,
databaseName: String =
@ -737,11 +753,12 @@ class TrainlogRepository(
return try {
db.beginTransaction()
incoming.forEach { item ->
val rowId = lookupExerciseRowIdOrNull(db, item.exerciseId)
val canonicalExerciseId = resolveExerciseId(db, item.exerciseId)
val rowId = lookupExerciseRowIdOrNull(db, canonicalExerciseId)
?: return ExerciseBodyZoneImportResult.Invalid(
"Exercice de relation inconnu : ${item.exerciseId}",
)
val local = readExerciseBodyZones(db, item.exerciseId)
val local = readExerciseBodyZones(db, canonicalExerciseId)
val localState = bodyZoneSyncState(local.first, local.second)
val incomingState = bodyZoneSyncState(item.primary, item.secondary)
val baseline = db.rawQuery(
@ -754,13 +771,13 @@ class TrainlogRepository(
skipped += 1
}
baseline != null && localState == baseline -> {
replaceExerciseBodyZones(db, item.exerciseId, item.primary, item.secondary)
replaceExerciseBodyZones(db, canonicalExerciseId, item.primary, item.secondary)
writeBodyZoneSyncBaseline(db, rowId, incomingState)
updated += 1
}
baseline != null && incomingState == baseline -> keptLocal += 1
baseline == null && localState == "|" -> {
replaceExerciseBodyZones(db, item.exerciseId, item.primary, item.secondary)
replaceExerciseBodyZones(db, canonicalExerciseId, item.primary, item.secondary)
writeBodyZoneSyncBaseline(db, rowId, incomingState)
updated += 1
}
@ -776,6 +793,126 @@ class TrainlogRepository(
}
}
/**
* CONTRACT: this companion is the only publication of retired exercise
* identities. Mappings are sorted, flattened, one hop, and never alter a
* mobile session exchange version.
*/
fun buildExerciseAliasesJson(): String {
val aliases = JSONArray()
database.readableDatabase.rawQuery(
"SELECT source_exercise_id,canonical_exercise_id FROM exercise_aliases " +
"ORDER BY source_exercise_id COLLATE BINARY;",
null,
).use { cursor ->
while (cursor.moveToNext()) {
aliases.put(JSONObject()
.put("source_exercise_id", cursor.getString(0))
.put("canonical_exercise_id", cursor.getString(1)))
}
}
check(aliases.length() <= MAX_EXERCISE_ALIASES) { "Trop d'alias exercice." }
return JSONObject()
.put("format", "trainlog-exercise-aliases")
.put("version", 1)
.put("aliases", aliases)
.toString()
}
fun applyExerciseAliasesJson(json: String): ExerciseAliasImportResult {
if (json.toByteArray(Charsets.UTF_8).size > MAX_EXERCISE_ALIAS_BYTES ||
!jsonHasUniqueObjectKeys(json)) {
return ExerciseAliasImportResult.Invalid("Artifact alias JSON invalide ou trop volumineux.")
}
val root = try { JSONObject(json) } catch (_: Exception) {
return ExerciseAliasImportResult.Invalid("Artifact alias JSON invalide.")
}
if (!root.hasExactKeys(setOf("format", "version", "aliases")) ||
root.value("format") != "trainlog-exercise-aliases" ||
!root.value("version").isJsonInt(1, 1) || root.value("aliases") !is JSONArray) {
return ExerciseAliasImportResult.Invalid("Artifact alias v1 non supporté.")
}
val incoming = mutableListOf<Pair<String, String>>()
val sources = mutableSetOf<String>()
val array = root.getJSONArray("aliases")
if (array.length() > MAX_EXERCISE_ALIASES)
return ExerciseAliasImportResult.Invalid("Trop d'alias exercice.")
for (index in 0 until array.length()) {
val item = array.opt(index) as? JSONObject
?: return ExerciseAliasImportResult.Invalid("Alias[$index] invalide.")
if (!item.hasExactKeys(setOf("source_exercise_id", "canonical_exercise_id")))
return ExerciseAliasImportResult.Invalid("Forme d'alias[$index] invalide.")
val source = item.optString("source_exercise_id")
val canonical = item.optString("canonical_exercise_id")
if (!source.matches(EXERCISE_ID_V4_PATTERN) ||
!canonical.matches(EXERCISE_ID_V4_PATTERN) || source == canonical ||
!sources.add(source)) {
return ExerciseAliasImportResult.Invalid("Identité d'alias[$index] invalide.")
}
incoming += source to canonical
}
if (incoming != incoming.sortedWith(compareBy<Pair<String, String>> { it.first }.thenBy { it.second }) ||
incoming.any { it.second in sources }) {
return ExerciseAliasImportResult.Invalid("Les alias doivent être triés et aplatis.")
}
val db = database.writableDatabase
var added = 0
var skipped = 0
return try {
db.beginTransaction()
incoming.forEach { (sourceId, canonicalId) ->
val canonical = findExerciseRow(db, "exercise_id=?", arrayOf(canonicalId))
?: return ExerciseAliasImportResult.Invalid(
"Cible canonique absente : $canonicalId",
)
val existing = db.rawQuery(
"SELECT canonical_exercise_id FROM exercise_aliases WHERE source_exercise_id=?;",
arrayOf(sourceId),
).use { cursor -> if (cursor.moveToFirst()) cursor.getString(0) else null }
if (existing != null) {
if (existing != canonicalId) return ExerciseAliasImportResult.Conflict(sourceId)
skipped += 1
return@forEach
}
val retired = findExerciseRow(db, "exercise_id=?", arrayOf(sourceId))
if (retired != null) {
if (retired.recordingMode != canonical.recordingMode ||
retired.trackingMode != canonical.trackingMode ||
retired.dataFields != canonical.dataFields ||
!aliasBodyZonesAreCompatible(db, canonical, retired) ||
!catalogEquipmentProfilesAreCompatible(db, sourceId, canonicalId)) {
return ExerciseAliasImportResult.Conflict(sourceId)
}
/* INVARIANT: flatten incoming edges before deleting the
* intermediate catalogue row protected by the alias FK. */
db.execSQL(
"UPDATE exercise_aliases SET canonical_exercise_id=? WHERE canonical_exercise_id=?;",
arrayOf(canonicalId, sourceId),
)
mergeAliasedExerciseRows(db, canonical, retired)
}
/* INVARIANT: aliases which previously targeted the retired ID
* remain one hop from a live catalogue row. */
db.execSQL(
"UPDATE exercise_aliases SET canonical_exercise_id=? WHERE canonical_exercise_id=?;",
arrayOf(canonicalId, sourceId),
)
db.execSQL(
"INSERT INTO exercise_aliases(source_exercise_id,canonical_exercise_id) VALUES(?,?);",
arrayOf(sourceId, canonicalId),
)
added += 1
}
db.setTransactionSuccessful()
ExerciseAliasImportResult.Applied(added, skipped)
} catch (_: Exception) {
ExerciseAliasImportResult.DatabaseError
} finally {
if (db.inTransaction()) db.endTransaction()
}
}
private fun bodyZoneSyncState(primary: String?, secondary: List<String>): String =
(primary ?: "") + "|" + secondary.sorted().joinToString(",")
@ -1390,10 +1527,12 @@ class TrainlogRepository(
index
)
val exerciseId =
item.getString(
"exercise_id"
)
val suppliedExerciseId = item.getString("exercise_id")
/* CONTRACT: a peer which has not yet consumed the companion
* may resend a retired catalogue ID; resolve it before lookup
* so the old row can never be resurrected. */
val exerciseId = resolveExerciseId(db, suppliedExerciseId)
val suppliedRetiredAlias = suppliedExerciseId != exerciseId
val name =
item.getString(
@ -1501,6 +1640,34 @@ class TrainlogRepository(
)
}
/* Retired identity is routing information only. Its stale
* name must not overwrite canonical presentation metadata
* or trigger a normalized-name merge. */
if (suppliedRetiredAlias) {
val richerFields = byId.dataFields or dataFields
if (richerFields == byId.dataFields) {
skipped += 1
traceDecision(
"exercise_alias:$suppliedExerciseId;exercise_id:${byId.exerciseId}",
"existing-identical",
)
} else {
if (db.update(
"exercises",
ContentValues().apply { put("data_fields", richerFields) },
"id = ?",
arrayOf(byId.rowId.toString()),
) != 1
) return PcCatalogImportResult.DatabaseError
reconciled += 1
traceDecision(
"exercise_alias:$suppliedExerciseId;exercise_id:${byId.exerciseId}",
"existing-reconciled",
)
}
continue
}
/* CONTRACT: a catalog name is mutable metadata. Identity
* reconciliation always prefers exercise_id, so an update
* retains the row used by completed sessions and drafts. */
@ -1817,12 +1984,69 @@ class TrainlogRepository(
retired.exerciseId,
canonical.exerciseId,
)
if (sqliteTableExists(db, "exercise_aliases")) {
db.execSQL(
"UPDATE exercise_aliases SET canonical_exercise_id=? WHERE canonical_exercise_id=?;",
arrayOf(canonical.exerciseId, retired.exerciseId),
)
db.execSQL(
"INSERT INTO exercise_aliases(source_exercise_id,canonical_exercise_id) VALUES(?,?);",
arrayOf(retired.exerciseId, canonical.exerciseId),
)
}
db.execSQL(
"DELETE FROM exercises WHERE id=?;",
arrayOf(retired.rowId),
)
}
private fun aliasBodyZonesAreCompatible(
db: SQLiteDatabase,
canonical: ExerciseRow,
retired: ExerciseRow,
): Boolean {
val canonicalPrimary = readExerciseBodyZones(db, canonical.exerciseId).first
val retiredPrimary = readExerciseBodyZones(db, retired.exerciseId).first
return canonicalPrimary == null || retiredPrimary == null ||
canonicalPrimary == retiredPrimary
}
private fun mergeAliasedExerciseRows(
db: SQLiteDatabase,
canonical: ExerciseRow,
retired: ExerciseRow,
) {
val canonicalZones = readExerciseBodyZones(db, canonical.exerciseId)
val retiredZones = readExerciseBodyZones(db, retired.exerciseId)
val primary = canonicalZones.first ?: retiredZones.first
val secondaries = (canonicalZones.second + retiredZones.second)
.filter { it != primary }.distinct()
replaceExerciseBodyZones(db, canonical.exerciseId, primary, secondaries)
db.execSQL("DELETE FROM exercise_body_zones WHERE exercise_row_id=?;", arrayOf(retired.rowId))
if (sqliteTableExists(db, "exercise_body_zone_sync")) {
db.execSQL(
"DELETE FROM exercise_body_zone_sync WHERE exercise_row_id IN(?,?);",
arrayOf(canonical.rowId, retired.rowId),
)
}
/* CONTRACT: row-owned occurrence and draft children keep their stable
* IDs and actual/planning values; only the catalogue FK is repointed. */
db.execSQL("UPDATE session_exercises SET exercise_row_id=? WHERE exercise_row_id=?;",
arrayOf(canonical.rowId, retired.rowId))
db.execSQL("UPDATE draft_session_exercises SET exercise_row_id=? WHERE exercise_row_id=?;",
arrayOf(canonical.rowId, retired.rowId))
db.execSQL("UPDATE active_session_draft SET selected_exercise_row_id=? WHERE selected_exercise_row_id=?;",
arrayOf(canonical.rowId, retired.rowId))
db.execSQL(
"INSERT OR IGNORE INTO exercise_equipment(exercise_row_id,equipment_row_id) " +
"SELECT ?,equipment_row_id FROM exercise_equipment WHERE exercise_row_id=?;",
arrayOf(canonical.rowId, retired.rowId),
)
db.execSQL("DELETE FROM exercise_equipment WHERE exercise_row_id=?;", arrayOf(retired.rowId))
mergeCatalogEquipmentIdentity(db, retired.exerciseId, canonical.exerciseId)
db.execSQL("DELETE FROM exercises WHERE id=?;", arrayOf(retired.rowId))
}
private fun sqliteTableExists(db: SQLiteDatabase, table: String): Boolean =
db.rawQuery(
"SELECT 1 FROM sqlite_master WHERE type='table' AND name=?;",
@ -1839,7 +2063,7 @@ class TrainlogRepository(
val retired = readExerciseBodyZones(db, retiredExerciseId)
val canonicalEmpty = canonical.first == null && canonical.second.isEmpty()
val retiredEmpty = retired.first == null && retired.second.isEmpty()
return canonicalEmpty || retiredEmpty || canonical == retired
return canonical.first == null || retired.first == null || canonical.first == retired.first
}
private fun mergeExerciseBodyZoneRows(
@ -1852,7 +2076,8 @@ class TrainlogRepository(
val retiredState = readExerciseBodyZones(db, retired.exerciseId)
val canonicalEmpty = canonicalState.first == null && canonicalState.second.isEmpty()
val retiredEmpty = retiredState.first == null && retiredState.second.isEmpty()
check(canonicalEmpty || retiredEmpty || canonicalState == retiredState) {
check(canonicalState.first == null || retiredState.first == null ||
canonicalState.first == retiredState.first) {
"Relations de zones incompatibles pendant la réconciliation d'identité."
}
@ -1862,17 +2087,21 @@ class TrainlogRepository(
* INVARIANT: baselines are cleared because an identity merge is not a
* synchronization acknowledgement; the next companion must establish
* a fresh common ancestor before accepting a one-sided change. */
if (canonicalEmpty && !retiredEmpty) {
db.execSQL(
"UPDATE exercise_body_zones SET exercise_row_id=? WHERE exercise_row_id=?;",
arrayOf(canonical.rowId, retired.rowId),
)
} else {
db.execSQL(
"DELETE FROM exercise_body_zones WHERE exercise_row_id=?;",
arrayOf(retired.rowId),
)
val primary = canonicalState.first ?: retiredState.first
if (primary != null) {
db.execSQL("DELETE FROM exercise_body_zones WHERE exercise_row_id=? AND zone_id=?;",
arrayOf<Any>(canonical.rowId, primary))
db.execSQL("INSERT INTO exercise_body_zones(exercise_row_id,zone_id,role) VALUES(?,?,'primary');",
arrayOf<Any>(canonical.rowId, primary))
}
db.execSQL(
"INSERT OR IGNORE INTO exercise_body_zones(exercise_row_id,zone_id,role) " +
"SELECT ?,zone_id,'secondary' FROM exercise_body_zones " +
"WHERE exercise_row_id=? AND role='secondary' AND zone_id<>COALESCE(?, '');",
arrayOf<Any?>(canonical.rowId, retired.rowId, primary),
)
db.execSQL("DELETE FROM exercise_body_zones WHERE exercise_row_id=?;",
arrayOf(retired.rowId))
if (sqliteTableExists(db, "exercise_body_zone_sync")) {
db.execSQL(
"DELETE FROM exercise_body_zone_sync WHERE exercise_row_id IN(?,?);",
@ -1943,8 +2172,8 @@ class TrainlogRepository(
private fun lookupExerciseRowIdOrNull(
db: SQLiteDatabase,
exerciseId: String,
): Long? =
db.query(
): Long? {
val direct = db.query(
"exercises",
arrayOf("id"),
"exercise_id = ?",
@ -1955,6 +2184,19 @@ class TrainlogRepository(
).use { cursor ->
if (cursor.moveToFirst()) cursor.getLong(0) else null
}
if (direct != null) return direct
return db.rawQuery(
"SELECT e.id FROM exercise_aliases a JOIN exercises e " +
"ON e.exercise_id=a.canonical_exercise_id WHERE a.source_exercise_id=?;",
arrayOf(exerciseId),
).use { cursor -> if (cursor.moveToFirst()) cursor.getLong(0) else null }
}
private fun resolveExerciseId(db: SQLiteDatabase, exerciseId: String): String =
db.rawQuery(
"SELECT canonical_exercise_id FROM exercise_aliases WHERE source_exercise_id=?;",
arrayOf(exerciseId),
).use { cursor -> if (cursor.moveToFirst()) cursor.getString(0) else exerciseId }
private fun lookupEquipmentRowIdOrNull(
db: SQLiteDatabase,
@ -2245,7 +2487,11 @@ class TrainlogRepository(
for (index in 0 until entries.length()) {
val entry = entries.getJSONObject(index)
val exerciseId = entry.optString("exercise_id")
val exerciseRow = findExerciseRow(db, "exercise_id = ?", arrayOf(exerciseId))
val exerciseRow = findExerciseRow(
db,
"exercise_id = ?",
arrayOf(resolveExerciseId(db, exerciseId)),
)
?: return MobileSessionImportResult.Invalid("Exercice V2 inconnu : $exerciseId")
if (
entry.optString("recording_mode") != exerciseRow.recordingMode.wireValue ||
@ -2289,7 +2535,11 @@ class TrainlogRepository(
if (entryId.isBlank() || !seen.add(entryId) || exerciseId.isBlank() || position < 0) {
return MobileSessionImportResult.Invalid("Identité d'entrée V2 invalide.")
}
val exerciseRow = findExerciseRow(db, "exercise_id = ?", arrayOf(exerciseId))
val exerciseRow = findExerciseRow(
db,
"exercise_id = ?",
arrayOf(resolveExerciseId(db, exerciseId)),
)
?: return MobileSessionImportResult.Invalid("Exercice V2 inconnu : $exerciseId")
val recording = entry.optString("recording_mode")
val tracking = entry.optString("tracking_mode")
@ -2658,7 +2908,8 @@ class TrainlogRepository(
if (rows.size != incoming.length()) return false
for (index in rows.indices) {
val item = incoming.getJSONObject(index)
val expected = mutableListOf<Any?>(item.optString("entry_id"), item.optInt("position", -1), item.optString("exercise_id"),
val expected = mutableListOf<Any?>(item.optString("entry_id"), item.optInt("position", -1),
resolveExerciseId(db, item.optString("exercise_id")),
item.optString("recording_mode"), item.optString("tracking_mode"), item.optInt("data_fields", -1),
if (item.isNull("equipment_id")) null else item.optString("equipment_id"))
if (version == 3) {
@ -2730,7 +2981,7 @@ class TrainlogRepository(
current[index] == Triple(
item.optString("entry_id"),
item.optInt("position", -1),
item.optString("exercise_id"),
resolveExerciseId(db, item.optString("exercise_id")),
)
}
}
@ -2932,6 +3183,7 @@ class TrainlogRepository(
val item = items.getJSONObject(index)
val sessionId = item.optString("session_id")
val exerciseId = item.optString("exercise_id")
val canonicalExerciseId = resolveExerciseId(db, exerciseId)
val entryId = if (version == 2) item.optString("entry_id") else null
val state = item.optString("state")
val identity = "$sessionId\u0000${entryId ?: exerciseId}"
@ -2948,19 +3200,19 @@ class TrainlogRepository(
} else {
"SELECT se.id,eq.equipment_id FROM session_exercises se JOIN sessions s ON s.id=se.session_row_id JOIN exercises e ON e.id=se.exercise_row_id LEFT JOIN equipment eq ON eq.id=se.equipment_row_id WHERE s.session_id=? AND e.exercise_id=?;"
},
if (version == 2) arrayOf(sessionId, entryId) else arrayOf(sessionId, exerciseId),
if (version == 2) arrayOf(sessionId, entryId) else arrayOf(sessionId, canonicalExerciseId),
).use { cursor ->
val first = if (cursor.moveToFirst()) {
if (version == 2) {
Triple(cursor.getLong(0), cursor.getString(1), if (cursor.isNull(2)) null else cursor.getString(2))
} else {
Triple(cursor.getLong(0), exerciseId, if (cursor.isNull(1)) null else cursor.getString(1))
Triple(cursor.getLong(0), canonicalExerciseId, if (cursor.isNull(1)) null else cursor.getString(1))
}
} else null
if (version == 1 && first != null && cursor.moveToNext()) null else first
}
?: return EquipmentAssociationImportResult.Invalid("Entrée de séance inconnue : $sessionId/$exerciseId")
if (row.second != exerciseId) {
if (row.second != canonicalExerciseId) {
return EquipmentAssociationImportResult.Invalid("Conflit exercice association : $sessionId/${entryId ?: exerciseId}")
}
if (row.third != equipmentId) {
@ -3587,12 +3839,16 @@ class TrainlogRepository(
targetRepetitions: Int,
restSeconds: Int,
manualWeightKg: Double?,
loadChoice: GeneratorLoadChoice = GeneratorLoadChoice.AUTOMATIC,
maxPercent: Int? = null,
): SessionGenerationResult {
val current = preview.exercises.getOrNull(index)
?: return SessionGenerationResult.Invalid("Exercice de proposition introuvable.")
if (targetSets !in 1..MAX_PLAN_SETS || targetRepetitions !in 1..MAX_PLAN_REPS ||
restSeconds !in 0..MAX_PLAN_REST_SECONDS ||
(manualWeightKg != null && (!manualWeightKg.isFinite() || manualWeightKg <= 0.0)))
(manualWeightKg != null && (!manualWeightKg.isFinite() || manualWeightKg <= 0.0)) ||
(loadChoice == GeneratorLoadChoice.PERCENT_MAX &&
(maxPercent == null || maxPercent !in 1..100)))
return SessionGenerationResult.Invalid("Dose cible invalide.")
val db = database.readableDatabase
val ownsTransaction = !db.inTransaction()
@ -3604,7 +3860,20 @@ class TrainlogRepository(
candidate, preview.request.referenceTime, targetSets, targetRepetitions, restSeconds,
) { visitor -> streamGenerationHistory(db, visitor) }
val semantics = candidate.equipmentLoadSemantics
val plan = if (manualWeightKg == null) {
val percentageTarget = if (loadChoice == GeneratorLoadChoice.PERCENT_MAX) {
readLatestExplicitMax(db, current.exerciseId, current.equipmentId)?.let { maximum ->
PercentMaxCalculator.calculate(maximum, current.equipmentId, checkNotNull(maxPercent))
}
} else null
val plan = if (loadChoice == GeneratorLoadChoice.NONE) {
SessionExercisePlan(targetSets, reps = targetRepetitions,
weightKg = null, loadMode = SessionLoadMode.NONE, restSeconds = restSeconds)
} else if (loadChoice == GeneratorLoadChoice.PERCENT_MAX) {
SessionExercisePlan(targetSets, reps = targetRepetitions,
weightKg = percentageTarget,
loadMode = if (percentageTarget == null) SessionLoadMode.NONE else SessionLoadMode.EXTERNAL,
restSeconds = restSeconds)
} else if (manualWeightKg == null) {
SessionExercisePlan(qualified.targetSets, reps = qualified.targetRepetitions,
weightKg = qualified.targetWeightKg, loadMode = qualified.plannedLoadMode,
restSeconds = qualified.restSeconds)
@ -3621,11 +3890,16 @@ class TrainlogRepository(
plan = plan,
estimatedSeconds = sessionGenerationEngine.estimateExerciseSeconds(
targetSets, targetRepetitions, restSeconds),
rationaleCodes = if (manualWeightKg == null) listOf(qualified.rationaleCode)
else listOf("manual_target_load"),
loadSourceSessionId = if (manualWeightKg == null) qualified.sourceSessionId else null,
loadSourceOccurrenceId = if (manualWeightKg == null) qualified.sourceOccurrenceId else null,
loadSourceStartedAt = if (manualWeightKg == null) qualified.sourceStartedAt else null,
rationaleCodes = when (loadChoice) {
GeneratorLoadChoice.PERCENT_MAX -> listOf(if (percentageTarget != null)
"user_selected_max_percentage" else "compatible_max_unavailable")
GeneratorLoadChoice.NONE -> listOf("numeric_load_absent")
GeneratorLoadChoice.AUTOMATIC -> if (manualWeightKg == null)
listOf(qualified.rationaleCode) else listOf("manual_target_load")
},
loadSourceSessionId = if (loadChoice == GeneratorLoadChoice.AUTOMATIC && manualWeightKg == null) qualified.sourceSessionId else null,
loadSourceOccurrenceId = if (loadChoice == GeneratorLoadChoice.AUTOMATIC && manualWeightKg == null) qualified.sourceOccurrenceId else null,
loadSourceStartedAt = if (loadChoice == GeneratorLoadChoice.AUTOMATIC && manualWeightKg == null) qualified.sourceStartedAt else null,
)
val exercises = preview.exercises.toMutableList().also { it[index] = changed }
val total = Math.addExact(sessionGenerationPolicy.preparationSeconds,
@ -3771,6 +4045,52 @@ class TrainlogRepository(
}
}
/**
* Read-only manual target calculation; it never creates or updates a draft.
* WHY: assistance and similar names cannot establish external resistance.
* CONTRACT: exact stable exercise/equipment IDs and the latest chronological
* explicit MAX in that context are required.
*/
fun calculateManualPercentMaxTarget(
exerciseId: String,
equipmentId: String?,
percent: Int,
): ManualPercentMaxResult {
if (percent !in 1..100)
return ManualPercentMaxResult.Unavailable("Le pourcentage doit être compris entre 1 et 100.")
if (equipmentId.isNullOrBlank())
return ManualPercentMaxResult.Unavailable(
"MAX compatible indisponible : choisissez un équipement à résistance externe.")
val db = database.readableDatabase
val ownsTransaction = !db.inTransaction()
return try {
if (ownsTransaction) db.beginTransactionNonExclusive()
if (readExerciseProfileExact(db, exerciseId) == null)
return ManualPercentMaxResult.Unavailable("Exercice introuvable.")
val runtimeEquipment = readRuntimeEquipment(db, equipmentId)
if (runtimeEquipment?.second == EquipmentLoadSemantics.ASSISTANCE)
return ManualPercentMaxResult.Unavailable(
"Le %MAX est indisponible pour une assistance ; choisissez une résistance externe.")
if (runtimeEquipment?.second != EquipmentLoadSemantics.EXTERNAL)
return ManualPercentMaxResult.Unavailable(
"Le %MAX est indisponible sans équipement connu à résistance externe.")
val maximum = readLatestExplicitMax(db, exerciseId, equipmentId)
?: return ManualPercentMaxResult.Unavailable(
"MAX compatible indisponible pour cet exercice et cet équipement exacts.")
val target = PercentMaxCalculator.calculate(maximum, equipmentId, percent)
?: return ManualPercentMaxResult.Unavailable(
"MAX compatible indisponible pour cet exercice et cet équipement exacts.")
if (ownsTransaction) db.setTransactionSuccessful()
/* INVARIANT: the repository returns arithmetic context only. The UI
* persists targetWeightKg solely when the user confirms its form. */
ManualPercentMaxResult.Available(maximum.maxWeightKg, maximum.startedAt, target)
} catch (error: Exception) {
ManualPercentMaxResult.Unavailable(error.message ?: "Lecture du MAX impossible.")
} finally {
if (ownsTransaction && db.inTransaction()) db.endTransaction()
}
}
/** Deterministic keyset page over current data; pages do not hold a cross-call snapshot. */
fun listExerciseOccurrences(
exerciseId: String,
@ -3902,6 +4222,41 @@ class TrainlogRepository(
}
}
/** Latest chronological explicit MAX for one exact external-load context. */
private fun readLatestExplicitMax(
db: SQLiteDatabase,
exerciseId: String,
equipmentId: String,
): ExplicitMaxContext? {
val selected = mutableListOf<TemporalCandidate>()
db.rawQuery(
"""SELECT se.id,s.session_id,se.entry_id,s.started_at FROM max_results mr
JOIN session_exercises se ON se.id=mr.session_exercise_row_id
JOIN sessions s ON s.id=se.session_row_id JOIN exercises e ON e.id=se.exercise_row_id
JOIN equipment eq ON eq.id=se.equipment_row_id
WHERE e.exercise_id=? AND eq.equipment_id=? AND eq.load_semantics='external';""",
arrayOf(exerciseId, equipmentId),
).use { cursor -> while (cursor.moveToNext()) retainCandidate(selected, candidate(cursor), 1) }
val winner = selected.singleOrNull() ?: return null
return db.rawQuery(
"""SELECT s.session_id,se.entry_id,s.started_at,mr.max_weight_kg,
eq.equipment_id,eq.display_name,eq.load_semantics
FROM max_results mr JOIN session_exercises se ON se.id=mr.session_exercise_row_id
JOIN sessions s ON s.id=se.session_row_id JOIN equipment eq ON eq.id=se.equipment_row_id
WHERE se.id=?;""",
arrayOf(winner.rowId.toString()),
).use { cursor ->
check(cursor.moveToFirst()) { "résultat MAX compatible sélectionné absent" }
ExplicitMaxContext(
cursor.requiredText(0, "session_id"), cursor.requiredText(1, "entry_id"),
cursor.requiredText(2, "started_at"),
cursor.finiteNonNegativeDouble(3, "max_weight_kg", strictlyPositive = true),
cursor.requiredText(4, "equipment_id"), cursor.requiredText(5, "display_name"),
parseLoadSemantics(cursor.requiredText(6, "load_semantics")),
)
}
}
private fun readExerciseOccurrencePage(
db: SQLiteDatabase, exerciseId: String, limit: Int, after: ExerciseOccurrenceCursor?, setPreviewLimit: Int,
): ExerciseOccurrencePage {
@ -4016,44 +4371,45 @@ class TrainlogRepository(
* presentation context only and never participates in max identity.
*/
fun listLatestExerciseMaxima(): List<LatestExerciseMax> {
val output = mutableListOf<LatestExerciseMax>()
data class AggregateCandidate(
val temporal: TemporalCandidate,
val result: LatestExerciseMax,
)
val selected = linkedMapOf<String, AggregateCandidate>()
database.readableDatabase.rawQuery(
"""
SELECT e.exercise_id, e.name, mr.max_weight_kg, s.started_at,
eq.display_name
SELECT se.id, s.session_id, se.entry_id, s.started_at,
e.exercise_id, e.name, mr.max_weight_kg, eq.display_name
FROM max_results AS mr
JOIN session_exercises AS se ON se.id = mr.session_exercise_row_id
JOIN sessions AS s ON s.id = se.session_row_id
JOIN exercises AS e ON e.id = se.exercise_row_id
LEFT JOIN equipment AS eq ON eq.id = se.equipment_row_id
WHERE s.session_type = 'max_test'
AND NOT EXISTS (
SELECT 1
FROM max_results AS newer_mr
JOIN session_exercises AS newer_se
ON newer_se.id = newer_mr.session_exercise_row_id
JOIN sessions AS newer_s ON newer_s.id = newer_se.session_row_id
WHERE newer_se.exercise_row_id = se.exercise_row_id
AND newer_s.session_type = 'max_test'
AND (newer_s.started_at > s.started_at OR
(newer_s.started_at = s.started_at AND
newer_se.position > se.position))
)
ORDER BY e.name COLLATE NOCASE, e.exercise_id;
""".trimIndent(),
null,
).use { cursor ->
while (cursor.moveToNext()) {
output += LatestExerciseMax(
exerciseId = cursor.getString(0),
exerciseName = cursor.getString(1),
maxWeightKg = cursor.getDouble(2),
startedAt = cursor.getString(3),
equipmentDisplayName = if (cursor.isNull(4)) null else cursor.getString(4),
val temporal = candidate(cursor)
val exerciseId = cursor.requiredText(4, "exercise_id")
val value = AggregateCandidate(
temporal = temporal,
result = LatestExerciseMax(
exerciseId = exerciseId,
exerciseName = cursor.requiredText(5, "exercise_name"),
maxWeightKg = cursor.finiteNonNegativeDouble(6, "max_weight_kg", true),
startedAt = temporal.startedAt,
equipmentDisplayName = cursor.optionalText(7),
),
)
val current = selected[exerciseId]
if (current == null || compareTemporal(temporal, current.temporal) > 0)
selected[exerciseId] = value
}
}
return output
return selected.values.map { it.result }
}
fun setCompletedSessionEquipment(
@ -5005,6 +5361,38 @@ private fun JSONObject.optDoubleOrNull(key: String): Double? =
private fun JSONObject.optIntOrNull(key: String): Int? =
if (has(key) && !isNull(key)) getInt(key) else null
private fun jsonHasUniqueObjectKeys(json: String): Boolean = try {
JsonReader(StringReader(json)).use { reader ->
fun consumeValue() {
when (reader.peek()) {
JsonToken.BEGIN_OBJECT -> {
reader.beginObject()
val names = mutableSetOf<String>()
while (reader.hasNext()) {
check(names.add(reader.nextName())) { "champ JSON dupliqué" }
consumeValue()
}
reader.endObject()
}
JsonToken.BEGIN_ARRAY -> {
reader.beginArray()
while (reader.hasNext()) consumeValue()
reader.endArray()
}
JsonToken.STRING, JsonToken.NUMBER -> reader.nextString()
JsonToken.BOOLEAN -> reader.nextBoolean()
JsonToken.NULL -> reader.nextNull()
else -> error("JSON incomplet")
}
}
consumeValue()
check(reader.peek() == JsonToken.END_DOCUMENT) { "JSON supplémentaire" }
}
true
} catch (_: Exception) {
false
}
private fun equipmentAliasNormalize(value: String): String =
Normalizer.normalize(value, Normalizer.Form.NFD)
.replace("\\p{M}+".toRegex(), "")
@ -5019,6 +5407,8 @@ private const val MAX_PLAN_SETS = 64
private const val MAX_PLAN_REPS = 10000
private const val MAX_PLAN_DURATION_SECONDS = 86400
private const val MAX_PLAN_REST_SECONDS = 86400
private const val MAX_EXERCISE_ALIAS_BYTES = 1024 * 1024
private const val MAX_EXERCISE_ALIASES = 4096
private const val ANDROID_LEG_PRESS_LEGACY_ID =
"ex_d68a1af1-7247-4fb3-a48b-da8516906a29"
private const val DESKTOP_LEG_PRESS_CANONICAL_ID =
@ -5034,7 +5424,7 @@ private class TrainlogDatabaseHelper(
appContext,
databaseName,
null,
11,
12,
) {
override fun onConfigure(
db: SQLiteDatabase,
@ -5062,6 +5452,7 @@ private class TrainlogDatabaseHelper(
createActiveDraftTables(db)
createEquipmentTables(db)
createBodyZoneTables(db)
createExerciseAliasTable(db)
seedEquipment(db)
}
@ -5156,6 +5547,11 @@ private class TrainlogDatabaseHelper(
version = 11
}
if (version < 12 && newVersion >= 12) {
createExerciseAliasTable(db)
version = 12
}
if (version != newVersion) {
error(
"Unsupported Android DB upgrade " +
@ -5185,6 +5581,16 @@ private class TrainlogDatabaseHelper(
)
}
private fun createExerciseAliasTable(db: SQLiteDatabase) {
db.execSQL(
"CREATE TABLE IF NOT EXISTS exercise_aliases(" +
"source_exercise_id TEXT PRIMARY KEY," +
"canonical_exercise_id TEXT NOT NULL REFERENCES exercises(exercise_id) ON DELETE RESTRICT," +
"CHECK(source_exercise_id<>canonical_exercise_id));",
)
db.execSQL("CREATE INDEX IF NOT EXISTS exercise_aliases_canonical ON exercise_aliases(canonical_exercise_id);")
}
private fun seedInitialBodyZones(db: SQLiteDatabase) {
val catalog = BodyZoneCatalog.load(appContext)
catalog.initialMappings.forEach { mapping ->

View file

@ -0,0 +1,123 @@
package com.labfytools.trainlog.ui
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.DrawerValue
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.ModalDrawerSheet
import androidx.compose.material3.ModalNavigationDrawer
import androidx.compose.material3.NavigationDrawerItem
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberDrawerState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp
import com.labfytools.trainlog.R
import com.labfytools.trainlog.ui.theme.LocalTrainlogColors
import kotlinx.coroutines.launch
private val drawerIcons = mapOf(
AppSection.HOME to R.drawable.ic_home,
AppSection.SESSIONS to R.drawable.ic_sessions,
AppSection.EXERCISES to R.drawable.ic_exercises,
AppSection.EQUIPMENT to R.drawable.ic_equipment,
AppSection.STATISTICS to R.drawable.ic_statistics,
AppSection.SYNC to R.drawable.ic_sync,
AppSection.SETTINGS to R.drawable.ic_settings,
)
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AndroidAppShell(
route: AppRoute,
onOpenSection: (AppSection) -> Unit,
onBack: () -> Boolean,
content: @Composable () -> Unit,
) {
val colors = LocalTrainlogColors.current
val drawerState = rememberDrawerState(DrawerValue.Closed)
val scope = rememberCoroutineScope()
val root = route == route.section.rootRoute()
val configuration = LocalConfiguration.current
val endMargin = if (configuration.fontScale >= 1.8f) 24.dp else 56.dp
val drawerWidth = minOf(360.dp, configuration.screenWidthDp.dp - endMargin)
BackHandler(enabled = drawerState.isOpen || route != AppRoute.Home) {
if (drawerState.isOpen) scope.launch { drawerState.close() } else onBack()
}
ModalNavigationDrawer(
drawerState = drawerState,
drawerContent = {
ModalDrawerSheet(
/* WHY: the seven destinations and brand must remain reachable
* in short landscape windows and at large system font scales. */
modifier = Modifier.width(drawerWidth).verticalScroll(rememberScrollState()),
drawerContainerColor = colors.mantle,
) {
Text("TRAINLOG", modifier = Modifier.padding(horizontal = 20.dp, vertical = 24.dp))
AppSection.entries.forEach { section ->
NavigationDrawerItem(
icon = { Icon(painterResource(drawerIcons.getValue(section)), contentDescription = null) },
label = { Text(section.label) },
selected = route.section == section,
onClick = {
onOpenSection(section)
scope.launch { drawerState.close() }
},
modifier = Modifier.padding(horizontal = 12.dp),
)
}
}
},
) {
Scaffold(
topBar = {
TopAppBar(
title = { Text(if (root) "TRAINLOG" else routeTitle(route)) },
navigationIcon = {
IconButton(
onClick = {
if (root) scope.launch { drawerState.open() } else onBack()
},
modifier = Modifier.semantics {
contentDescription = if (root) "Ouvrir la navigation" else "Retour"
},
) {
Icon(
painterResource(if (root) R.drawable.ic_menu else R.drawable.ic_back),
contentDescription = null,
)
}
},
actions = {
if (!root) {
IconButton(
onClick = { scope.launch { drawerState.open() } },
modifier = Modifier.semantics { contentDescription = "Navigation" },
) { Icon(painterResource(R.drawable.ic_menu), contentDescription = null) }
}
},
colors = TopAppBarDefaults.topAppBarColors(containerColor = colors.crust),
)
},
) { padding ->
Box(Modifier.fillMaxSize().padding(padding)) { content() }
}
}
}

View file

@ -0,0 +1,187 @@
package com.labfytools.trainlog.ui
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
enum class AppSection(val label: String) {
HOME("Accueil"), SESSIONS("Séances"), EXERCISES("Exercices"),
EQUIPMENT("Équipements"), STATISTICS("Statistiques"), SYNC("Synchronisation"), SETTINGS("Paramètres"),
}
sealed interface AppRoute {
val section: AppSection
data object Home : AppRoute { override val section = AppSection.HOME }
data object Sessions : AppRoute { override val section = AppSection.SESSIONS }
data object SessionEditor : AppRoute { override val section = AppSection.SESSIONS }
data object SessionGenerator : AppRoute { override val section = AppSection.SESSIONS }
data object CompletedSessions : AppRoute { override val section = AppSection.SESSIONS }
data class SessionDetail(val sessionId: String) : AppRoute { override val section = AppSection.SESSIONS }
data object Exercises : AppRoute { override val section = AppSection.EXERCISES }
data class ExerciseDetail(val exerciseId: String) : AppRoute { override val section = AppSection.EXERCISES }
data class ExerciseEdit(val exerciseId: String, val caller: AppRoute) : AppRoute { override val section = caller.section }
data class ExerciseCreate(val caller: AppRoute) : AppRoute { override val section = caller.section }
data object Equipment : AppRoute { override val section = AppSection.EQUIPMENT }
data class EquipmentDetail(val equipmentId: String) : AppRoute { override val section = AppSection.EQUIPMENT }
data class EquipmentCreate(val caller: AppRoute) : AppRoute { override val section = caller.section }
data object Statistics : AppRoute { override val section = AppSection.STATISTICS }
data object BodyMeasurements : AppRoute { override val section = AppSection.STATISTICS }
data object LatestMaxima : AppRoute { override val section = AppSection.STATISTICS }
data object Sync : AppRoute { override val section = AppSection.SYNC }
data object Settings : AppRoute { override val section = AppSection.SETTINGS }
}
fun AppSection.rootRoute(): AppRoute = when (this) {
AppSection.HOME -> AppRoute.Home
AppSection.SESSIONS -> AppRoute.Sessions
AppSection.EXERCISES -> AppRoute.Exercises
AppSection.EQUIPMENT -> AppRoute.Equipment
AppSection.STATISTICS -> AppRoute.Statistics
AppSection.SYNC -> AppRoute.Sync
AppSection.SETTINGS -> AppRoute.Settings
}
/** CONTRACT: one owner performs every route transaction; routes never mutate persistence. */
class AppNavigationState(initial: AppRoute = AppRoute.Home, private val limit: Int = 16) {
private val history = ArrayDeque<AppRoute>()
var route: AppRoute by mutableStateOf(initial)
private set
fun open(destination: AppRoute) {
if (destination == route) return
history.addLast(route)
while (history.size > limit) history.removeFirst()
route = destination
}
fun openSection(section: AppSection) {
history.clear()
route = section.rootRoute()
}
fun back(): Boolean {
val caller = when (val current = route) {
is AppRoute.ExerciseCreate -> current.caller
is AppRoute.ExerciseEdit -> current.caller
is AppRoute.EquipmentCreate -> current.caller
else -> null
}
/* INVARIANT: inline creation returns to its declared caller exactly
* once. When open() recorded that caller, consume the matching entry
* so the following Back continues beyond it instead of becoming a
* no-op on the same route. */
if (caller != null && history.lastOrNull() == caller) history.removeLast()
route = caller ?: history.removeLastOrNull() ?: when (route) {
AppRoute.Home -> return false
else -> route.section.rootRoute().takeUnless { it == route } ?: AppRoute.Home
}
return true
}
}
/**
* WHY: root navigation guards are product behavior, so their owner must be
* callable by both [TrainlogApp] and host-side regression tests.
* CONTRACT: route requests never save, synchronize, finalize, or mutate the
* repository; explicit discard clears only the transient state for the route
* that raised the guard.
*/
class AppNavigationController(
val navigation: AppNavigationState,
private val generator: SessionGeneratorUiState,
private val equipment: EquipmentScreenState,
private val exercise: ExerciseScreenState,
private val body: BodyScreenState,
) {
private var pendingAction: (() -> Unit)? by mutableStateOf(null)
private var guardedRoute: AppRoute? = null
private var replacementBlocked = false
val hasPendingNavigation: Boolean get() = pendingAction != null
val pendingRoute: AppRoute? get() = guardedRoute
private fun isDirty(route: AppRoute): Boolean =
route == AppRoute.SessionGenerator && generator.hasUnacceptedWork ||
route is AppRoute.EquipmentCreate && equipment.dirty ||
(route is AppRoute.ExerciseCreate || route is AppRoute.ExerciseEdit) && exercise.dirty ||
route == AppRoute.BodyMeasurements && body.dirty
private fun request(replacesEditor: Boolean = false, action: () -> Unit): Boolean {
val source = navigation.route
return if (isDirty(source)) {
guardedRoute = source
replacementBlocked = replacesEditor
pendingAction = action
false
} else {
action()
true
}
}
fun open(route: AppRoute): Boolean {
val source = navigation.route
val replacement =
(source is AppRoute.ExerciseCreate || source is AppRoute.ExerciseEdit) &&
(route is AppRoute.ExerciseCreate || route is AppRoute.ExerciseEdit) && route != source
return request(replacement) { navigation.open(route) }
}
fun openSection(section: AppSection): Boolean = request { navigation.openSection(section) }
fun back(): Boolean = request { navigation.back() }
fun cancelPending() { pendingAction = null; guardedRoute = null; replacementBlocked = false }
fun keepAndNavigate() {
/* INVARIANT: retaining a dirty form also retains its route identity;
* a different target may only replace it after explicit discard. */
if (replacementBlocked) cancelPending() else resolve(discard = false)
}
fun discardAndNavigate() = resolve(discard = true)
private fun resolve(discard: Boolean) {
val action = pendingAction ?: return
val source = guardedRoute
if (discard) when (source) {
AppRoute.SessionGenerator -> generator.abandon()
is AppRoute.EquipmentCreate -> equipment.abandonEdits()
AppRoute.BodyMeasurements -> body.abandonEdits()
is AppRoute.ExerciseCreate, is AppRoute.ExerciseEdit -> exercise.abandonEdits()
else -> Unit
}
cancelPending()
action()
}
}
internal fun routeTitle(route: AppRoute): String = when (route) {
AppRoute.Home -> "Accueil"
AppRoute.Sessions -> "Séances"
AppRoute.SessionEditor -> "Séance en cours"
AppRoute.SessionGenerator -> "Programmer une séance"
AppRoute.CompletedSessions -> "Séances effectuées"
is AppRoute.SessionDetail -> "Détail de la séance"
AppRoute.Exercises -> "Exercices"
is AppRoute.ExerciseDetail -> "Fiche exercice"
is AppRoute.ExerciseEdit -> "Modifier l'exercice"
is AppRoute.ExerciseCreate -> "Créer un exercice"
AppRoute.Equipment -> "Équipements"
is AppRoute.EquipmentDetail -> "Fiche équipement"
is AppRoute.EquipmentCreate -> "Créer un équipement"
AppRoute.Statistics -> "Statistiques"
AppRoute.BodyMeasurements -> "Mensurations"
AppRoute.LatestMaxima -> "Derniers MAX"
AppRoute.Sync -> "Synchronisation"
AppRoute.Settings -> "Paramètres"
}
/**
* WHY: configuration recreation must retain small navigation and form state,
* while process death deliberately restores only the repository-owned draft.
* No session or proposal is serialized into a Bundle.
*/
class TrainlogAppState : ViewModel() {
val navigation = AppNavigationState()
val generator = SessionGeneratorUiState()
val equipment = EquipmentScreenState()
val exercise = ExerciseScreenState()
val body = BodyScreenState()
val navigationController = AppNavigationController(navigation, generator, equipment, exercise, body)
}

View file

@ -21,9 +21,16 @@ import com.labfytools.trainlog.data.TrainlogRepository
import com.labfytools.trainlog.model.BodyObservationDraft
import com.labfytools.trainlog.ui.theme.LocalTrainlogColors
class BodyScreenState {
val values = List(14) { mutableStateOf("") }
val dirty: Boolean get() = values.any { it.value.isNotEmpty() }
fun abandonEdits() { values.forEach { it.value = "" } }
}
@Composable
fun BodyScreen(
repository: TrainlogRepository,
state: BodyScreenState,
onBodySaved: () -> Unit,
onBack: () -> Unit,
) {
@ -36,75 +43,13 @@ fun BodyScreen(
val keyboardController =
LocalSoftwareKeyboardController.current
var bodyWeight by
remember {
mutableStateOf("")
}
var neck by
remember {
mutableStateOf("")
}
var shoulders by
remember {
mutableStateOf("")
}
var chest by
remember {
mutableStateOf("")
}
var waist by
remember {
mutableStateOf("")
}
var hips by
remember {
mutableStateOf("")
}
var leftArm by
remember {
mutableStateOf("")
}
var rightArm by
remember {
mutableStateOf("")
}
var leftForearm by
remember {
mutableStateOf("")
}
var rightForearm by
remember {
mutableStateOf("")
}
var leftThigh by
remember {
mutableStateOf("")
}
var rightThigh by
remember {
mutableStateOf("")
}
var leftCalf by
remember {
mutableStateOf("")
}
var rightCalf by
remember {
mutableStateOf("")
}
var bodyWeight by state.values[0]; var neck by state.values[1]
var shoulders by state.values[2]; var chest by state.values[3]
var waist by state.values[4]; var hips by state.values[5]
var leftArm by state.values[6]; var rightArm by state.values[7]
var leftForearm by state.values[8]; var rightForearm by state.values[9]
var leftThigh by state.values[10]; var rightThigh by state.values[11]
var leftCalf by state.values[12]; var rightCalf by state.values[13]
var message by
remember {
@ -127,35 +72,14 @@ fun BodyScreen(
}
fun clearForm() {
bodyWeight = ""
neck = ""
shoulders = ""
chest = ""
waist = ""
hips = ""
leftArm = ""
rightArm = ""
leftForearm = ""
rightForearm = ""
leftThigh = ""
rightThigh = ""
leftCalf = ""
rightCalf = ""
state.abandonEdits()
}
TrainlogScreen(
subtitle = "M E N S U R A T I O N S"
subtitle = "Mensurations"
) {
TrainlogAction(
label = "< Retour",
description =
"Revenir à l'accueil.",
onClick = onBack,
accent = colors.muted,
)
TrainlogFrame(
title = "GENERAL"
title = "Général"
) {
BodyMetricField(
label = "Poids",
@ -219,7 +143,7 @@ fun BodyScreen(
}
TrainlogFrame(
title = "MEMBRES"
title = "Membres"
) {
BodyMetricField(
label = "Bras gauche",
@ -303,7 +227,7 @@ fun BodyScreen(
}
TrainlogFrame(
title = "ENREGISTREMENT"
title = "Enregistrement"
) {
TrainlogAction(
label =
@ -447,7 +371,7 @@ fun BodyScreen(
}
TrainlogFrame(
title = "DERNIERS RELEVES",
title = "Derniers relevés",
active =
recent.isNotEmpty(),
) {

View file

@ -5,14 +5,18 @@ import androidx.compose.foundation.clickable
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.padding
import androidx.compose.foundation.text.BasicText
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.semantics.selected
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.labfytools.trainlog.data.CreateExerciseResult
@ -32,74 +36,90 @@ import com.labfytools.trainlog.model.TrackingMode
import com.labfytools.trainlog.ui.theme.LocalTrainlogColors
import com.labfytools.trainlog.ui.theme.TrainlogTypography
class ExerciseScreenState {
val name = mutableStateOf("")
val recordingMode = mutableStateOf(RecordingMode.SETS)
val trackingMode = mutableStateOf(TrackingMode.REPS)
val speed = mutableStateOf(false)
val distance = mutableStateOf(false)
val primaryZoneId = mutableStateOf<String?>(null)
val secondaryZoneIds = mutableStateOf(emptySet<String>())
val searchQuery = mutableStateOf("")
val filterZoneId = mutableStateOf<String?>(null)
val unclassifiedFilter = mutableStateOf(false)
val expandedKnowledgeIds = mutableStateOf(emptySet<String>())
val message = mutableStateOf<String?>(null)
val editedExerciseId = mutableStateOf<String?>(null)
var selectedExerciseId by mutableStateOf<String?>(null)
var catalogueScroll by mutableStateOf(0)
var detailScroll by mutableStateOf(0)
private var cleanSignature = signature()
val dirty: Boolean get() = signature() != cleanSignature
fun markClean() { cleanSignature = signature() }
fun prepareCreate() {
if (editedExerciseId.value == null && !dirty) abandonEdits()
}
fun prepareEdit(exercise: ExerciseProfile) {
if (editedExerciseId.value == exercise.exerciseId) return
require(!dirty) { "un éditeur sale ne peut pas être remplacé" }
editedExerciseId.value = exercise.exerciseId
name.value = exercise.name
recordingMode.value = exercise.recordingMode
trackingMode.value = exercise.trackingMode
speed.value = exercise.dataFields and ExerciseDataFields.SPEED_KMH != 0
distance.value = exercise.dataFields and ExerciseDataFields.DISTANCE_KM != 0
primaryZoneId.value = exercise.primaryZoneId
secondaryZoneIds.value = exercise.secondaryZoneIds.toSet()
message.value = null
markClean()
}
fun abandonEdits() {
name.value = ""; recordingMode.value = RecordingMode.SETS; trackingMode.value = TrackingMode.REPS
speed.value = false; distance.value = false; primaryZoneId.value = null
secondaryZoneIds.value = emptySet(); editedExerciseId.value = null; message.value = null; markClean()
}
private fun signature(): String = listOf(name.value, recordingMode.value, trackingMode.value, speed.value,
distance.value, primaryZoneId.value, secondaryZoneIds.value.sorted(), editedExerciseId.value).joinToString("|")
}
@Composable
fun ExerciseScreen(
repository: TrainlogRepository,
state: ExerciseScreenState,
inline: Boolean,
onBack: () -> Unit,
onSaved: () -> Unit,
onOpenMaxima: () -> Unit,
catalogueVisible: Boolean = true,
) {
val colors =
LocalTrainlogColors.current
var name by
remember {
mutableStateOf("")
}
var recordingMode by
remember {
mutableStateOf(
RecordingMode.SETS
)
}
var trackingMode by
remember {
mutableStateOf(
TrackingMode.REPS
)
}
var speed by
remember {
mutableStateOf(false)
}
var distance by
remember {
mutableStateOf(false)
}
var primaryZoneId by remember { mutableStateOf<String?>(null) }
var secondaryZoneIds by remember { mutableStateOf(emptySet<String>()) }
var searchQuery by remember { mutableStateOf("") }
var filterZoneId by remember { mutableStateOf<String?>(null) }
var unclassifiedFilter by remember { mutableStateOf(false) }
var expandedKnowledgeIds by remember { mutableStateOf(emptySet<String>()) }
var name by state.name
var recordingMode by state.recordingMode
var trackingMode by state.trackingMode
var speed by state.speed
var distance by state.distance
var primaryZoneId by state.primaryZoneId
var secondaryZoneIds by state.secondaryZoneIds
var searchQuery by state.searchQuery
var filterZoneId by state.filterZoneId
var unclassifiedFilter by state.unclassifiedFilter
var expandedKnowledgeIds by state.expandedKnowledgeIds
val zones = repository.listBodyZones()
var message by
remember {
mutableStateOf<String?>(
null
)
}
var editedExercise by
remember {
mutableStateOf<ExerciseProfile?>(null)
}
var message by state.message
var editedExerciseId by state.editedExerciseId
val profileLocked =
editedExercise?.let {
!repository.canEditExerciseProfile(it.exerciseId)
editedExerciseId?.let {
!repository.canEditExerciseProfile(it)
} ?: false
fun startEditing(exercise: ExerciseProfile) {
/* WHY: edit state copies catalog metadata for presentation only. The
* repository remains the sole owner of stable identity and SQLite. */
editedExercise = exercise
editedExerciseId = exercise.exerciseId
name = exercise.name
recordingMode = exercise.recordingMode
trackingMode = exercise.trackingMode
@ -108,34 +128,18 @@ fun ExerciseScreen(
primaryZoneId = exercise.primaryZoneId
secondaryZoneIds = exercise.secondaryZoneIds.toSet()
message = null
state.markClean()
}
TrainlogScreen(
subtitle = "E X E R C I C E"
subtitle = if (editedExerciseId == null) "Créer un exercice" else "Modifier l'exercice"
) {
TrainlogAction(
label =
if (inline) {
"< Retour à la séance"
} else {
"< Retour"
},
description =
if (inline) {
"Retourner dans la séance en cours."
} else {
"Revenir à l'accueil."
},
onClick = onBack,
accent = colors.muted,
)
TrainlogFrame(
title =
if (editedExercise == null) {
"NOUVEL EXERCICE"
if (editedExerciseId == null) {
"Nouvel exercice"
} else {
"MODIFIER L'EXERCICE"
"Modifier l'exercice"
}
) {
TrainlogField(
@ -350,9 +354,9 @@ fun ExerciseScreen(
)
}
TrainlogAction(
TrainlogPrimaryAction(
label =
if (editedExercise != null) {
if (editedExerciseId != null) {
"Enregistrer les modifications"
} else if (inline) {
"Créer et revenir à la séance"
@ -360,20 +364,18 @@ fun ExerciseScreen(
"Enregistrer l'exercice"
},
description =
if (editedExercise == null) {
if (editedExerciseId == null) {
"Ajouter ce profil au catalogue local."
} else {
"Conserver l'identité et mettre à jour le catalogue."
},
accent =
colors.success,
onClick = save@{
if (editedExercise == null &&
if (editedExerciseId == null &&
recordingMode == RecordingMode.SETS && primaryZoneId == null) {
message = "Une zone principale est requise pour un nouvel exercice musculaire."
return@save
}
val current = editedExercise
val current = editedExerciseId
val result =
if (current == null) {
repository.createExercise(
@ -392,7 +394,7 @@ fun ExerciseScreen(
} else {
repository.editExercise(
ExerciseEditInput(
exerciseId = current.exerciseId,
exerciseId = current,
name = name,
recordingMode = recordingMode,
trackingMode = trackingMode,
@ -405,6 +407,7 @@ fun ExerciseScreen(
when (result) {
is CreateExerciseResult.Created -> {
message = null
state.abandonEdits()
onSaved()
}
@ -424,7 +427,8 @@ fun ExerciseScreen(
is EditExerciseResult.Saved -> {
message = null
editedExercise = null
editedExerciseId = null
state.abandonEdits()
onSaved()
}
@ -448,13 +452,13 @@ fun ExerciseScreen(
},
)
if (editedExercise != null) {
if (editedExerciseId != null) {
TrainlogAction(
label = "Annuler",
description = "Revenir au catalogue sans modification.",
accent = colors.muted,
onClick = {
editedExercise = null
editedExerciseId = null
name = ""
recordingMode = RecordingMode.SETS
trackingMode = TrackingMode.REPS
@ -476,7 +480,7 @@ fun ExerciseScreen(
}
}
TrainlogFrame(title = "EXERCICES EXISTANTS", active = false) {
if (catalogueVisible) TrainlogFrame(title = "Exercices existants", active = false) {
TrainlogInputField(
label = "Recherche par préfixe",
value = searchQuery,
@ -533,12 +537,18 @@ fun ExerciseScreen(
)
if (expanded) KnowledgePanel(repository, knowledge)
}
TrainlogAction(
label = "Voir les derniers MAX",
description = "Ouvrir la liste agrégée existante dans Statistiques.",
accent = colors.warning,
onClick = onOpenMaxima,
)
}
}
}
TrainlogFrame(
title = "CONTRAT",
title = "Contrat",
active = false,
) {
TrainlogInfo(
@ -552,6 +562,29 @@ fun ExerciseScreen(
}
}
@Composable
fun ExerciseEditorRoute(
repository: TrainlogRepository,
state: ExerciseScreenState,
exerciseId: String?,
inline: Boolean,
onSaved: () -> Unit,
) {
val profile = remember(exerciseId) {
exerciseId?.let { id -> repository.listExercises().firstOrNull { it.exerciseId == id } }
}
LaunchedEffect(exerciseId) {
if (profile == null) state.prepareCreate() else state.prepareEdit(profile)
}
if (exerciseId != null && profile == null) {
TrainlogScreen("Modifier l'exercice") { TrainlogInfo("Exercice introuvable.") }
} else if (state.editedExerciseId.value != exerciseId) {
TrainlogScreen("Modifier l'exercice") { TrainlogInfo("Chargement du profil…") }
} else {
ExerciseScreen(repository, state, inline, {}, onSaved, {}, catalogueVisible = false)
}
}
@Composable
private fun KnowledgePanel(repository: TrainlogRepository, knowledge: ExerciseKnowledge) {
val colors = LocalTrainlogColors.current
@ -564,31 +597,115 @@ private fun KnowledgePanel(repository: TrainlogRepository, knowledge: ExerciseKn
}
if (interpretation == null) {
TrainlogInfo("Classification scientifique non résolue.", colors.warning)
return
}
if (knowledge.resolutionStatus == ExerciseKnowledgeStatus.CONDITIONAL) {
TrainlogInfo("Confiance : ${confidenceLabel(knowledge.confidence)}", colors.muted)
} else if (knowledge.resolutionStatus == ExerciseKnowledgeStatus.CONDITIONAL) {
TrainlogInfo(
"Interprétation conditionnelle · à confirmer : ${interpretation.requiredConfirmation.orEmpty()}",
colors.warning,
)
}
val patterns = interpretation.patternIds.mapNotNull(repository::getMovementPatternKnowledge)
val primary = interpretation.primaryMuscleIds.mapNotNull(repository::getMuscleKnowledge)
val secondary = interpretation.secondaryMuscleIds.mapNotNull(repository::getMuscleKnowledge)
val primaryZone = repository.bodyZone(interpretation.primaryZoneId)?.displayName
?: interpretation.primaryZoneId
val secondaryZones = interpretation.secondaryZoneIds.map { repository.bodyZone(it)?.displayName ?: it }
interpretation?.let { resolved ->
val patterns = resolved.patternIds.mapNotNull(repository::getMovementPatternKnowledge)
val primary = resolved.primaryMuscleIds.mapNotNull(repository::getMuscleKnowledge)
val secondary = resolved.secondaryMuscleIds.mapNotNull(repository::getMuscleKnowledge)
val primaryZone = repository.bodyZone(resolved.primaryZoneId)?.displayName ?: resolved.primaryZoneId
val secondaryZones = resolved.secondaryZoneIds.map { repository.bodyZone(it)?.displayName ?: it }
val runtimeEquipment = repository.listEquipment().associateBy { it.equipmentId }
val equipment = knowledge.equipmentIds.map { runtimeEquipment[it]?.displayName ?: it }
TrainlogInfo("Mouvement : ${patterns.joinToString { it.displayNameFr }.ifEmpty { "Non classé" }}")
TrainlogInfo("Muscles principaux : ${primary.joinToString { it.displayNameFr }.ifEmpty { "Non classés" }}")
TrainlogInfo("Muscles secondaires : ${secondary.joinToString { it.displayNameFr }.ifEmpty { "Aucun établi" }}")
TrainlogInfo(
"Zones scientifiques : $primaryZone" +
if (secondaryZones.isEmpty()) "" else " · secondaires : ${secondaryZones.joinToString()}",
)
TrainlogInfo("Zones scientifiques : $primaryZone" + if (secondaryZones.isEmpty()) "" else " · secondaires : ${secondaryZones.joinToString()}")
TrainlogInfo("Équipement compatible : ${equipment.joinToString().ifEmpty { "Non établi" }}")
TrainlogInfo("Confiance : ${confidenceLabel(interpretation.confidence)}", colors.muted)
TrainlogInfo("Confiance : ${confidenceLabel(resolved.confidence)}", colors.muted)
}
knowledge.limitations.forEach { TrainlogInfo("Limite : $it", colors.muted) }
knowledge.sourceRefs.mapNotNull(repository::getScienceReference).forEach { reference ->
TrainlogInfo(
"Source : ${reference.authorsOrOrganization} · ${reference.title}" +
(reference.year?.let { " ($it)" } ?: ""),
colors.muted,
)
}
}
/** CONTRACT: the section root is a catalogue; creation and detail are explicit routes. */
@Composable
fun ExerciseCatalogueScreen(
repository: TrainlogRepository,
state: ExerciseScreenState,
onCreate: () -> Unit,
onOpenDetail: (String) -> Unit,
) {
var query by state.searchQuery
var zoneId by state.filterZoneId
var unclassified by state.unclassifiedFilter
val zones = repository.listBodyZones()
val exercises = repository.listExercises(query, zoneId, true, false, unclassified)
TrainlogScreen("Catalogue d'exercices", scrollKey = "exercise-catalogue") {
TrainlogPrimaryAction("Créer un exercice", "Ajouter un profil au catalogue local.", onCreate)
TrainlogInputField("Recherche par préfixe", query, { query = it })
TrainlogChoiceGroup("Filtrer") {
TrainlogChoice("Toutes les zones", zoneId == null && !unclassified) {
zoneId = null; unclassified = false
}
zones.filter { it.parentZoneId == null }.forEach { zone ->
TrainlogChoice(zone.displayName, zoneId == zone.zoneId && !unclassified) {
zoneId = zone.zoneId; unclassified = false
}
}
TrainlogChoice("Non renseignés", unclassified) { zoneId = null; unclassified = true }
}
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)
})
}
}
}
}
@Composable
fun ExerciseDetailScreen(
repository: TrainlogRepository,
exerciseId: String,
onModify: () -> Unit,
onOpenMaxima: () -> Unit,
) {
val colors = LocalTrainlogColors.current
val context = remember(exerciseId) { repository.getTrainingExerciseContext(exerciseId) }
TrainlogScreen("Fiche exercice", scrollKey = "exercise-detail:$exerciseId") {
if (context == null) {
TrainlogInfo("Exercice introuvable.", colors.error)
return@TrainlogScreen
}
TrainlogFrame("Profil") {
TrainlogInfo(context.exercise.name, colors.accent)
TrainlogInfo(exerciseZoneSummary(repository, context.exercise))
TrainlogInfo(profilePreview(context.exercise.recordingMode, context.exercise.trackingMode, context.exercise.dataFields))
TrainlogAction("Modifier", "Modifier le nom, le profil et les zones selon les règles existantes.", onModify)
}
TrainlogFrame("Connaissances", active = context.knowledge != null) {
context.knowledge?.let { KnowledgePanel(repository, it) }
?: TrainlogInfo("Aucune connaissance scientifique liée à cet identifiant.", colors.muted)
}
TrainlogFrame("Équipements compatibles", active = context.compatibleEquipment.isNotEmpty()) {
if (context.compatibleEquipment.isEmpty()) TrainlogInfo("Compatibilité non établie.")
context.compatibleEquipment.forEach { equipment ->
val name = listOfNotNull(equipment.manufacturer, equipment.model).joinToString(" ").ifBlank { equipment.equipmentId }
TrainlogInfo(name)
}
}
context.latestExplicitMax?.let { max ->
TrainlogFrame("MAX") {
TrainlogInfo("Dernier résultat : ${max.maxWeightKg} kg · ${max.startedAt.take(10)}", colors.warning)
}
}
TrainlogAction("Voir les derniers MAX", "Ouvrir la liste agrégée existante dans Statistiques.", onOpenMaxima)
}
}
private fun knowledgeSummary(knowledge: ExerciseKnowledge): String = when (knowledge.resolutionStatus) {
@ -695,6 +812,7 @@ private fun TrainlogChoice(
Modifier
.fillMaxWidth()
.padding(vertical = 2.dp)
.heightIn(min = 48.dp)
.background(
if (selected) {
colors.surfaceAlt
@ -702,6 +820,7 @@ private fun TrainlogChoice(
colors.surface
}
)
.semantics { this.selected = selected }
.clickable(
enabled = enabled,
onClick = onClick,
@ -722,7 +841,7 @@ private fun TrainlogChoice(
TrainlogTypography.normal.copy(
color =
if (selected) {
colors.warning
colors.accent
} else if (!enabled) {
colors.muted
} else {

View file

@ -25,18 +25,10 @@ fun HistoryScreen(
}
TrainlogScreen(
subtitle = "H I S T O R I Q U E"
subtitle = "Séances effectuées"
) {
TrainlogAction(
label = "< Retour",
description =
"Revenir à l'accueil.",
onClick = onBack,
accent = colors.muted,
)
TrainlogFrame(
title = "SEANCES",
title = "Séances",
active =
sessions.isNotEmpty(),
) {
@ -79,7 +71,7 @@ fun HistoryScreen(
}
TrainlogFrame(
title = "DERNIERS MAX",
title = "Derniers MAX",
active = latestMaxima.isNotEmpty(),
) {
if (latestMaxima.isEmpty()) {

View file

@ -1,11 +1,9 @@
package com.labfytools.trainlog.ui
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import com.labfytools.trainlog.model.ActiveSessionDraft
import com.labfytools.trainlog.model.LatestExerciseMax
import com.labfytools.trainlog.model.SessionSummary
import com.labfytools.trainlog.model.SessionType
import com.labfytools.trainlog.ui.theme.LocalTrainlogColors
@ -13,162 +11,39 @@ import com.labfytools.trainlog.ui.theme.LocalTrainlogColors
fun HomeScreen(
activeDraft: ActiveSessionDraft?,
draftError: String?,
latestSession: SessionSummary?,
latestMaximum: LatestExerciseMax?,
onSession: () -> Unit,
onGenerateSession: () -> Unit,
onDiscardDraft: () -> Unit,
onExercise: () -> Unit,
onBody: () -> Unit,
onHistory: () -> Unit,
onOpenLatestSession: (String) -> Unit,
onSync: () -> Unit,
) {
val colors = LocalTrainlogColors.current
var confirmingDiscard by
remember(activeDraft != null) {
mutableStateOf(false)
}
TrainlogScreen(
subtitle = "A C C U E I L"
) {
TrainlogScreen("Accueil", scrollKey = "home") {
draftError?.let { TrainlogInfo(it, colors.error) }
TrainlogFrame("Séance") {
if (activeDraft != null) {
TrainlogFrame(
title = "SÉANCE EN COURS"
) {
TrainlogAction(
label =
"Reprendre la séance en cours",
description =
(if (
activeDraft.sessionType ==
SessionType.MAX_TEST
) {
"Test max"
val kind = if (activeDraft.sessionType == SessionType.MAX_TEST) "Test max" else "Entraînement"
TrainlogPrimaryAction("Reprendre la séance en cours", "$kind · ${activeDraft.exercises.size} exercice(s)", onSession)
TrainlogAction("Programmer une séance", "La proposition sera conservée séparément.", onGenerateSession)
} else {
"Entraînement"
}) +
" · ${activeDraft.exercises.size} exercice(s)",
accent = colors.success,
onClick = onSession,
)
TrainlogAction(
label =
"Supprimer la séance en cours",
description =
"Supprimer le brouillon, sans modifier l'historique.",
accent = colors.error,
onClick = {
confirmingDiscard = true
},
)
if (confirmingDiscard) {
TrainlogAction(
label =
"Confirmer la suppression",
description =
"Abandonner définitivement cette séance en cours.",
accent = colors.error,
onClick = {
confirmingDiscard = false
onDiscardDraft()
},
)
TrainlogAction(
label = "Annuler",
description =
"Conserver la séance en cours.",
accent = colors.muted,
onClick = {
confirmingDiscard = false
},
)
}
}
}
if (draftError != null) {
TrainlogFrame(
title = "BROUILLON"
) {
TrainlogInfo(
text = draftError,
color = colors.error,
)
}
}
TrainlogFrame(
title = "ENREGISTREMENT"
) {
TrainlogAction(
label =
"Enregistrer une séance",
description =
if (activeDraft == null) {
"Saisir un entraînement et ses exercices."
} else {
"Ouvrir la séance en cours sans l'écraser."
},
onClick = onSession,
)
TrainlogAction(
label = "Générer une séance",
description = "Préparer une proposition modifiable à partir d'une zone, d'un objectif et d'une durée.",
onClick = onGenerateSession,
)
TrainlogAction(
label =
"Enregistrer un exercice",
description =
"Créer une entrée dans le catalogue Trainlog.",
onClick = onExercise,
)
TrainlogAction(
label =
"Enregistrer des mensurations",
description =
"Ajouter un relevé corporel.",
onClick = onBody,
)
}
TrainlogFrame(
title = "CONSULTATION"
) {
TrainlogAction(
label =
"Historique des séances",
description =
"Consulter les séances enregistrées et leur détail.",
onClick = onHistory,
)
}
TrainlogFrame(
title = "SYNCHRONISATION"
) {
TrainlogAction(
label = "Synchroniser avec le PC",
description = "Préparer les données pour le transport MTP.",
onClick = onSync,
)
}
TrainlogFrame(
title = "STATUT",
active = false,
) {
TrainlogInfo(
"Stockage Android local actif."
)
TrainlogInfo(
"Synchronisation MTP : après les workflows locaux."
)
TrainlogPrimaryAction("Programmer une séance", "Préparer une proposition modifiable à partir d'une zone, d'un objectif et d'une durée.", onGenerateSession)
TrainlogAction("Nouvelle séance manuelle", "Créer explicitement un brouillon durable.", onSession)
}
}
latestSession?.let { session ->
TrainlogFrame("Dernière séance") {
TrainlogAction(formatStartedAt(session.startedAt), "${sessionTypeLabel(session.sessionType)} · ${session.exerciseCount} exercice(s)", { onOpenLatestSession(session.sessionId) })
}
}
latestMaximum?.let { maximum ->
val weight = "%.2f".format(java.util.Locale.FRANCE, maximum.maxWeightKg).trimEnd('0').trimEnd(',')
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)
}
}
}

View file

@ -0,0 +1,190 @@
package com.labfytools.trainlog.ui
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import com.labfytools.trainlog.data.CreateEquipmentResult
import com.labfytools.trainlog.data.CatalogInboxResult
import com.labfytools.trainlog.data.EquipmentCatalogEntry
import com.labfytools.trainlog.data.EquipmentLoadSemantics
import com.labfytools.trainlog.data.SyncCatalogInbox
import com.labfytools.trainlog.data.TrainlogRepository
import com.labfytools.trainlog.model.ActiveSessionDraft
import com.labfytools.trainlog.ui.theme.LocalTrainlogColors
@Composable
fun SessionsHub(
draft: ActiveSessionDraft?,
onResume: () -> Unit,
onGenerate: () -> Unit,
onManual: () -> Unit,
onHistory: () -> Unit,
) {
val colors = LocalTrainlogColors.current
TrainlogScreen("Séances") {
TrainlogFrame("Séance en cours", active = draft != null) {
if (draft == null) TrainlogInfo("Aucune séance en cours.")
else TrainlogAction("Reprendre", "${draft.exercises.size} exercice(s) · le brouillon durable est conservé.", onResume, accent = colors.success)
}
TrainlogFrame("Préparer") {
TrainlogAction("Programmer une séance", "Générer une proposition modifiable.", onGenerate, accent = if (draft == null) colors.success else colors.accent)
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("Séances effectuées", "Consulter les actuals, plans et MAX enregistrés.", onHistory)
}
}
@Composable
fun StatisticsHub(onBody: () -> Unit, onMaxima: () -> Unit) {
TrainlogScreen("Statistiques") {
TrainlogFrame("Données existantes") {
TrainlogAction("Mensurations", "Ajouter et consulter les relevés corporels locaux.", onBody)
TrainlogAction("Derniers MAX", "Consulter les derniers résultats MAX explicites par exercice.", onMaxima)
}
TrainlogInfo("Les tendances et analyses supplémentaires restent sur le PC canonique.")
}
}
@Composable
fun LatestMaximaScreen(repository: TrainlogRepository) {
val colors = LocalTrainlogColors.current
val maxima = remember { repository.listLatestExerciseMaxima() }
TrainlogScreen("Derniers MAX") {
TrainlogFrame("CAPACITÉS / MAX", active = maxima.isNotEmpty()) {
if (maxima.isEmpty()) TrainlogInfo("Aucun max explicite enregistré.")
maxima.forEach { max ->
val weight = "%.2f".format(java.util.Locale.FRANCE, max.maxWeightKg).trimEnd('0').trimEnd(',')
TrainlogInfo("${max.exerciseName} · $weight kg", colors.warning)
TrainlogInfo("${formatStartedAt(max.startedAt).take(10)} · Équipement : ${max.equipmentDisplayName ?: "aucun"}", colors.muted)
}
}
}
}
class EquipmentScreenState {
var query by mutableStateOf("")
var customName by mutableStateOf("")
var selectedEquipmentId by mutableStateOf<String?>(null)
var catalogueScroll by mutableStateOf(0)
var detailScroll by mutableStateOf(0)
var message by mutableStateOf<String?>(null)
var revision by mutableStateOf(0)
val dirty: Boolean get() = customName.isNotEmpty()
fun abandonEdits() { customName = ""; message = null }
}
@Composable
fun EquipmentScreen(
repository: TrainlogRepository,
state: EquipmentScreenState,
onCreate: () -> Unit,
onOpenDetail: (String) -> Unit,
) {
val equipment = remember(state.query, state.revision) { repository.searchEquipment(state.query) }
TrainlogScreen("Catalogue d'équipements", scrollKey = "equipment-catalogue") {
TrainlogPrimaryAction("Créer un équipement", "Ajouter une machine personnelle.", onCreate)
TrainlogInputField("Rechercher par nom, étiquette ou alias", state.query, { state.query = it })
TrainlogFrame("Catalogue", active = equipment.isNotEmpty()) {
if (equipment.isEmpty()) TrainlogInfo("Aucun équipement trouvé.")
equipment.forEach { entry ->
TrainlogAction(entry.displayName, listOf(entry.labelName, equipmentSemanticsLabel(entry.loadSemantics)).filter { it.isNotBlank() }.joinToString(" · "), {
state.selectedEquipmentId = entry.equipmentId
onOpenDetail(entry.equipmentId)
})
}
}
}
}
@Composable
fun EquipmentDetailScreen(repository: TrainlogRepository, equipmentId: String) {
val colors = LocalTrainlogColors.current
val entry = remember(equipmentId) { repository.listEquipment().firstOrNull { it.equipmentId == equipmentId } }
TrainlogScreen("Fiche équipement", scrollKey = "equipment-detail:$equipmentId") {
if (entry == null) {
TrainlogInfo("Référence inconnue · $equipmentId", colors.warning)
return@TrainlogScreen
}
TrainlogFrame("Définition") {
TrainlogInfo(entry.displayName, colors.accent)
TrainlogInfo(if (entry.type == "custom_machine") "Personnel" else "Fourni")
if (entry.labelName.isNotBlank()) TrainlogInfo("Étiquette : ${entry.labelName}")
TrainlogInfo("Type : ${entry.type}")
TrainlogInfo("Charge : ${equipmentSemanticsLabel(entry.loadSemantics)}")
if (entry.aliases.isNotEmpty()) TrainlogInfo("Alias : ${entry.aliases.joinToString()}", colors.muted)
}
}
}
@Composable
fun EquipmentCreateScreen(repository: TrainlogRepository, state: EquipmentScreenState, onCreated: () -> Unit) {
val colors = LocalTrainlogColors.current
TrainlogScreen("Créer un équipement", scrollKey = "equipment-create") {
TrainlogFrame("Équipement personnel") {
TrainlogInputField("Nom", state.customName, { state.customName = it; state.message = null })
TrainlogPrimaryAction("Créer", "Créer une machine personnelle avec la sémantique actuelle.") {
when (val result = repository.createCustomEquipment(state.customName)) {
is CreateEquipmentResult.Created -> {
state.selectedEquipmentId = result.equipment.equipmentId
state.customName = ""; state.message = null; state.revision++; onCreated()
}
CreateEquipmentResult.Invalid -> state.message = "Saisissez un nom de 1 à 120 caractères."
CreateEquipmentResult.Conflict -> state.message = "Un équipement porte déjà ce nom."
is CreateEquipmentResult.DatabaseError -> state.message = "Création impossible : ${result.message}."
}
}
state.message?.let { TrainlogInfo(it, colors.error) }
}
}
}
private fun equipmentSemanticsLabel(value: EquipmentLoadSemantics) = when (value) {
EquipmentLoadSemantics.NONE -> "sans charge"
EquipmentLoadSemantics.EXTERNAL -> "charge externe"
EquipmentLoadSemantics.ASSISTANCE -> "assistance"
EquipmentLoadSemantics.BODYWEIGHT -> "poids du corps"
EquipmentLoadSemantics.CARDIO -> "cardio"
}
@Composable
fun SettingsScreen(inbox: SyncCatalogInbox, onCatalogChanged: () -> Unit) {
val colors = LocalTrainlogColors.current
var authorized by remember { mutableStateOf(inbox.hasFolderAccess()) }
var message by remember { mutableStateOf<String?>(null) }
val launcher = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocumentTree()) { uri ->
if (uri != null) {
authorized = inbox.saveTreeUri(uri)
message = if (authorized) "Dossier d'échange autorisé." else "Autorisation du dossier impossible."
if (authorized) {
when (val result = inbox.importPcCatalog()) {
is CatalogInboxResult.Imported -> {
message = "Dossier autorisé · ${result.imported} exercice(s) importé(s), ${result.reconciled} réconcilié(s)."
onCatalogChanged()
}
CatalogInboxResult.FileNotFound -> message = "Dossier autorisé · aucun catalogue PC reçu."
CatalogInboxResult.FolderNotAuthorized -> message = "Le dossier n'est plus autorisé."
is CatalogInboxResult.Error -> message = result.message
}
}
}
}
TrainlogScreen("Paramètres") {
TrainlogFrame("DOSSIER D'ÉCHANGE") {
TrainlogInfo(if (authorized) "Téléchargements/Trainlog autorisé." else "Aucun dossier Trainlog autorisé.", if (authorized) colors.success else colors.warning)
TrainlogAction(if (authorized) "Changer de dossier" else "Autoriser le dossier", "Choisir le dossier d'échange avec le sélecteur Android.", { launcher.launch(null) })
if (authorized) TrainlogAction("Relire le catalogue PC", "Appliquer explicitement le catalogue présent dans le dossier autorisé.", {
when (val result = inbox.importPcCatalog()) {
is CatalogInboxResult.Imported -> { message = "Catalogue relu · ${result.imported} nouveau(x), ${result.reconciled} réconcilié(s)."; onCatalogChanged() }
CatalogInboxResult.FileNotFound -> message = "Aucun catalogue PC reçu."
CatalogInboxResult.FolderNotAuthorized -> { authorized = false; message = "Le dossier n'est plus autorisé." }
is CatalogInboxResult.Error -> message = result.message
}
})
message?.let { TrainlogInfo(it, if (authorized) colors.success else colors.error) }
}
}
}

View file

@ -44,20 +44,11 @@ fun SessionDetailScreen(
}
TrainlogScreen(
subtitle = "D E T A I L S E A N C E"
subtitle = "Détail de la séance"
) {
TrainlogAction(
label =
"< Retour à l'historique",
description =
"Revenir à la liste des séances.",
onClick = onBack,
accent = colors.muted,
)
if (detail == null) {
TrainlogFrame(
title = "ERREUR"
title = "Erreur"
) {
TrainlogInfo(
text =
@ -71,7 +62,7 @@ fun SessionDetailScreen(
}
TrainlogFrame(
title = "SEANCE"
title = "Séance"
) {
TrainlogInfo(
formatStartedAt(

View file

@ -8,6 +8,7 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import com.labfytools.trainlog.data.AcceptGeneratedSessionResult
import com.labfytools.trainlog.data.GenerationWarningLevel
import com.labfytools.trainlog.data.GeneratorLoadChoice
import com.labfytools.trainlog.data.SessionGenerationPreview
import com.labfytools.trainlog.data.SessionGenerationRequest
import com.labfytools.trainlog.data.SessionGenerationResult
@ -55,9 +56,53 @@ internal object SessionGeneratorFormController {
}
}
/**
* INVARIANT: a generated proposal is transient application state. Keeping this
* holder above the route preserves edits across drawer navigation without ever
* representing the proposal as the repository-owned active draft.
*/
class SessionGeneratorUiState {
val zoneId = mutableStateOf("full_body")
val goalId = mutableStateOf("general")
val durationText = mutableStateOf("30")
val preview = mutableStateOf<SessionGenerationPreview?>(null)
val message = mutableStateOf<String?>(null)
val warningAcknowledged = mutableStateOf(false)
val editingIndex = mutableStateOf<Int?>(null)
val setsText = mutableStateOf("")
val repsText = mutableStateOf("")
val restText = mutableStateOf("")
val loadText = mutableStateOf("")
val loadChoice = mutableStateOf(GeneratorLoadChoice.AUTOMATIC)
val maxPercentText = mutableStateOf("70")
val busy = mutableStateOf(false)
var requestIdentity: Long = 0
val hasUnacceptedWork: Boolean
get() = preview.value != null || zoneId.value != "full_body" ||
goalId.value != "general" || durationText.value != "30"
fun abandon() {
zoneId.value = "full_body"
goalId.value = "general"
durationText.value = "30"
preview.value = null
message.value = null
warningAcknowledged.value = false
editingIndex.value = null
setsText.value = ""
repsText.value = ""
restText.value = ""
loadText.value = ""
loadChoice.value = GeneratorLoadChoice.AUTOMATIC
maxPercentText.value = "70"
}
}
@Composable
fun SessionGeneratorScreen(
repository: TrainlogRepository,
state: SessionGeneratorUiState,
onBack: () -> Unit,
onAccepted: () -> Unit,
onExistingDraft: () -> Unit,
@ -66,32 +111,38 @@ fun SessionGeneratorScreen(
val scope = rememberCoroutineScope()
val options = remember { repository.sessionGenerationFormOptions() }
val zones = remember { repository.listBodyZones().filter { it.zoneId in options.zoneIds } }
var zoneId by remember { mutableStateOf("full_body") }
var goalId by remember { mutableStateOf("general") }
var durationText by remember { mutableStateOf("30") }
var preview by remember { mutableStateOf<SessionGenerationPreview?>(null) }
var message by remember { mutableStateOf<String?>(null) }
var warningAcknowledged by remember { mutableStateOf(false) }
var editingIndex by remember { mutableStateOf<Int?>(null) }
var setsText by remember { mutableStateOf("") }
var repsText by remember { mutableStateOf("") }
var restText by remember { mutableStateOf("") }
var loadText by remember { mutableStateOf("") }
var busy by remember { mutableStateOf(false) }
var zoneId by state.zoneId
var goalId by state.goalId
var durationText by state.durationText
var preview by state.preview
var message by state.message
var warningAcknowledged by state.warningAcknowledged
var editingIndex by state.editingIndex
var setsText by state.setsText
var repsText by state.repsText
var restText by state.restText
var loadText by state.loadText
var loadChoice by state.loadChoice
var maxPercentText by state.maxPercentText
var busy by state.busy
fun generate(request: SessionGenerationRequest) {
if (busy) return
val requestIdentity = ++state.requestIdentity
scope.launch {
busy = true
try {
when (val result = withContext(Dispatchers.IO) { repository.generateSessionPreview(request) }) {
is SessionGenerationResult.Generated -> {
/* INVARIANT: an obsolete async generation request may
* not replace a newer proposal after navigation. */
if (requestIdentity != state.requestIdentity) return@launch
preview = result.preview
warningAcknowledged = result.preview.exposure.warningLevel == GenerationWarningLevel.NONE
message = null
}
is SessionGenerationResult.Invalid -> message = result.message
is SessionGenerationResult.DatabaseError -> message = result.message
is SessionGenerationResult.Invalid -> if (requestIdentity == state.requestIdentity) message = result.message
is SessionGenerationResult.DatabaseError -> if (requestIdentity == state.requestIdentity) message = result.message
}
} finally {
busy = false
@ -99,30 +150,17 @@ fun SessionGeneratorScreen(
}
}
TrainlogScreen(subtitle = "G E N E R E R U N E S E A N C E") {
TrainlogAction("< Retour", "Annuler sans créer de brouillon ni modifier l'historique.", onClick = {
if (!busy) onBack()
}, accent = colors.muted)
TrainlogScreen(subtitle = "Programmer une séance") {
val current = preview
if (current == null) {
TrainlogFrame("ZONE CORPORELLE") {
zones.forEach { zone ->
TrainlogAction(zone.displayName, zone.zoneId,
onClick = { zoneId = zone.zoneId },
accent = if (zone.zoneId == zoneId) colors.success else colors.muted)
TrainlogFrame("Zone corporelle") {
TrainlogChoiceChips(zones.map { it.zoneId to it.displayName }, zoneId) { zoneId = it }
}
TrainlogFrame("Objectif") {
TrainlogChoiceChips(SessionGeneratorFormController.goals.filter { it.first in options.goalIds }, goalId) { goalId = it }
}
TrainlogFrame("OBJECTIF") {
SessionGeneratorFormController.goals.filter { it.first in options.goalIds }.forEach { (id, label) ->
TrainlogAction(label, id, onClick = { goalId = id },
accent = if (goalId == id) colors.success else colors.muted)
}
}
TrainlogFrame("DURÉE") {
options.durationPresets.forEach { minutes ->
TrainlogAction("$minutes min", "Durée disponible.", onClick = { durationText = minutes.toString() },
accent = if (durationText == minutes.toString()) colors.success else colors.muted)
}
TrainlogFrame("Durée") {
TrainlogChoiceChips(options.durationPresets.map { it.toString() to "$it min" }, durationText) { durationText = it }
TrainlogInputField(
"Durée personnalisée (${options.customMinutes.first} à ${options.customMinutes.last} min)",
durationText,
@ -135,8 +173,11 @@ fun SessionGeneratorScreen(
else generate(SessionGenerationRequest(zoneId, goalId, minutes, OffsetDateTime.now().toString()))
})
} else {
TrainlogFrame("PROPOSITION") {
TrainlogInfo("Durée estimée : ${current.estimatedDurationSeconds / 60} min")
TrainlogFrame("Proposition") {
TrainlogInfo("Durée cible : ${current.request.durationMinutes} min · estimation : ${current.estimatedDurationSeconds / 60} min")
if (current.request.durationMinutes * 60 - current.estimatedDurationSeconds >= 300)
TrainlogInfo("La proposition est nettement plus courte que la cible ; aucun exercice n'est ajouté pour remplir artificiellement le temps.", colors.warning)
TrainlogInfo("Échauffement et retour au calme ne sont pas générés en V1.", colors.muted)
if (current.insufficientResolvedCandidates) TrainlogInfo(
"La couverture est incomplète faute de contextes résolus disponibles" +
current.shortageCodes.takeIf { it.isNotEmpty() }
@ -166,8 +207,7 @@ fun SessionGeneratorScreen(
TrainlogInfo("Historique récent similaire détecté ; information uniquement.", colors.warning)
item.loadSourceStartedAt?.let {
TrainlogInfo(
"Charge issue d'une dose réellement observée le $it " +
"(séance ${item.loadSourceSessionId}, passage ${item.loadSourceOccurrenceId}) ; " +
"Charge issue d'une dose réellement observée le $it ; " +
"son applicabilité aujourd'hui reste incertaine.",
colors.muted,
)
@ -180,13 +220,27 @@ fun SessionGeneratorScreen(
TrainlogInputField("Séries", setsText, onValueChange = { setsText = it })
TrainlogInputField("Répétitions", repsText, onValueChange = { repsText = it })
TrainlogInputField("Repos (secondes)", restText, onValueChange = { restText = it })
TrainlogInfo("Charge : choix utilisateur, jamais une recommandation.", colors.muted)
TrainlogChoiceChips(
listOf("AUTOMATIC" to "Automatique", "PERCENT_MAX" to "% MAX", "NONE" to "Aucune"),
loadChoice.name,
) { selected ->
loadChoice = GeneratorLoadChoice.valueOf(selected)
if (loadChoice != GeneratorLoadChoice.AUTOMATIC) loadText = ""
}
if (loadChoice == GeneratorLoadChoice.PERCENT_MAX)
TrainlogInputField("Pourcentage du MAX (1 à 100)", maxPercentText,
onValueChange = { maxPercentText = it })
if (loadChoice == GeneratorLoadChoice.AUTOMATIC)
TrainlogInputField(
"Charge cible manuelle (vide = réévaluer)",
"Charge cible manuelle (vide = automatique)",
loadText,
onValueChange = { loadText = it },
)
TrainlogAction("Appliquer", "Réestimer la durée et requalifier la charge observée.", accent = colors.success, onClick = {
val parsedWeight = SessionGeneratorFormController.manualWeight(loadText)
val parsedWeight = if (loadChoice == GeneratorLoadChoice.AUTOMATIC)
SessionGeneratorFormController.manualWeight(loadText)
else Result.success(null)
if (parsedWeight.isFailure) {
message = parsedWeight.exceptionOrNull()?.message
} else {
@ -197,7 +251,8 @@ fun SessionGeneratorScreen(
when (val result = withContext(Dispatchers.IO) {
repository.editGeneratedDose(current, index,
setsText.toIntOrNull() ?: -1, repsText.toIntOrNull() ?: -1,
restText.toIntOrNull() ?: -1, weight)
restText.toIntOrNull() ?: -1, weight, loadChoice,
maxPercentText.toIntOrNull())
}) {
is SessionGenerationResult.Generated -> { preview = result.preview; editingIndex = null; message = null }
is SessionGenerationResult.Invalid -> message = result.message
@ -216,6 +271,12 @@ fun SessionGeneratorScreen(
// Preserve an explicit user value across later edits. An
// automatic observed value stays display-only: empty asks
// the engine to qualify it again for the changed dose.
loadChoice = when {
"user_selected_max_percentage" in item.rationaleCodes ||
"compatible_max_unavailable" in item.rationaleCodes -> GeneratorLoadChoice.PERCENT_MAX
"numeric_load_absent" in item.rationaleCodes -> GeneratorLoadChoice.NONE
else -> GeneratorLoadChoice.AUTOMATIC
}
loadText = if ("manual_target_load" in item.rationaleCodes)
item.plan.weightKg?.toString().orEmpty() else ""
}
@ -252,14 +313,16 @@ fun SessionGeneratorScreen(
if (!busy) onBack()
}, accent = colors.muted)
}
if (busy) TrainlogFrame("TRAITEMENT") { TrainlogInfo("Analyse en cours…", colors.muted) }
message?.let { TrainlogFrame("MESSAGE") { TrainlogInfo(it, colors.error) } }
if (busy) TrainlogFrame("Traitement") { TrainlogInfo("Analyse en cours…", colors.muted) }
message?.let { TrainlogFrame("Message") { TrainlogInfo(it, colors.error) } }
}
}
private fun generationReasonLabel(code: String): String = when (code) {
"observed_repeated_dose_anchor" -> "dose répétée observée"
"explicit_max_present_no_numeric_prescription" -> "maximum observé sans prescription numérique"
"user_selected_max_percentage" -> "pourcentage de MAX choisi par l'utilisateur"
"compatible_max_unavailable" -> "MAX compatible indisponible"
"assistance_numeric_load_omitted" -> "charge d'assistance omise"
"numeric_load_absent" -> "charge numérique absente"
"manual_target_load" -> "charge saisie manuellement"

View file

@ -30,6 +30,7 @@ import com.labfytools.trainlog.data.ActiveDraftMutationResult
import com.labfytools.trainlog.data.CreateEquipmentResult
import com.labfytools.trainlog.data.EquipmentLoadSemantics
import com.labfytools.trainlog.data.FinalizeActiveDraftResult
import com.labfytools.trainlog.data.ManualPercentMaxResult
import com.labfytools.trainlog.data.TrainlogRepository
import com.labfytools.trainlog.model.ActiveSessionDraft
import com.labfytools.trainlog.model.ExerciseDataFields
@ -37,6 +38,8 @@ import com.labfytools.trainlog.model.ExerciseProfile
import com.labfytools.trainlog.model.RecordingMode
import com.labfytools.trainlog.model.SessionDraftForm
import com.labfytools.trainlog.model.SessionExerciseDraft
import com.labfytools.trainlog.model.SessionExercisePlan
import com.labfytools.trainlog.model.SessionLoadMode
import com.labfytools.trainlog.model.SessionSetDraft
import com.labfytools.trainlog.model.SessionType
import com.labfytools.trainlog.model.TrackingMode
@ -124,20 +127,11 @@ fun SessionScreen(
}
TrainlogScreen(
subtitle = "S E A N C E"
subtitle = "Séance en cours"
) {
TrainlogAction(
label = "< Retour",
description =
"Revenir à l'accueil sans supprimer la séance en cours.",
/* CONTRACT: ordinary navigation never owns draft deletion. */
onClick = onBack,
accent = colors.muted,
)
if (activeDraft == null) {
TrainlogFrame(
title = "ERREUR"
title = "Erreur"
) {
TrainlogInfo(
text = message.orEmpty(),
@ -169,7 +163,7 @@ fun SessionScreen(
)
TrainlogFrame(
title = "TYPE DE SEANCE"
title = "Type de séance"
) {
TrainlogAction(
label =
@ -252,7 +246,7 @@ fun SessionScreen(
)
TrainlogFrame(
title = "SEANCE EN COURS"
title = "Séance en cours"
) {
TrainlogInfo(
text =
@ -367,6 +361,9 @@ fun SessionScreen(
sessionType = currentDraft.sessionType,
initialForm =
currentDraft.form,
initialPlan = currentDraft.form.editingExerciseIndex?.let {
currentDraft.exercises.getOrNull(it)?.plan
},
onFormChanged = {
form ->
persistDraft(
@ -397,9 +394,7 @@ fun SessionScreen(
currentDraft.exercises.mapIndexed { index, existing ->
/* INVARIANT: normal performed-value edits
* preserve generator planning metadata. */
if (index == replacingIndex) {
draft.copy(plan = existing.plan)
} else existing
if (index == replacingIndex) draft else existing
}
} ?: (currentDraft.exercises + draft),
form =
@ -412,7 +407,7 @@ fun SessionScreen(
}
TrainlogFrame(
title = "EXERCICES"
title = "Exercices"
) {
TrainlogAction(
label =
@ -427,7 +422,7 @@ fun SessionScreen(
}
TrainlogFrame(
title = "ENREGISTREMENT",
title = "Enregistrement",
active =
currentDraft.exercises.isNotEmpty(),
) {
@ -554,6 +549,7 @@ private fun CatalogChoice(
.then(modifier)
.fillMaxWidth()
.padding(vertical = 2.dp)
.heightIn(min = 48.dp)
.background(
if (selected) {
colors.surfaceAlt
@ -621,7 +617,7 @@ private fun ExercisePicker(
var query by remember(selectedExercise?.exerciseId) { mutableStateOf("") }
val results = remember(exercises, query) { exercisePrefixMatches(exercises, query) }
TrainlogFrame(title = "EXERCICE", active = exercises.isNotEmpty()) {
TrainlogFrame(title = "Exercice", active = exercises.isNotEmpty()) {
if (exercises.isEmpty()) {
TrainlogInfo("Aucun exercice.")
return@TrainlogFrame
@ -704,6 +700,65 @@ private fun normalizeExerciseSearchText(value: String): String =
.trim()
.replace("\\s+".toRegex(), " ")
internal enum class ManualTargetChoice { KG, PERCENT_MAX, NONE }
/**
* Build occurrence planning metadata separately from performed rows.
* CONTRACT: an existing generated/manual dose keeps its sets/reps/duration/rest;
* only the user-selected load target changes. A new manual occurrence derives
* its initial dose shape from the confirmed performed-row form without copying
* target weight into any performed set.
*/
internal fun buildManualTargetPlan(
draft: SessionExerciseDraft,
existing: SessionExercisePlan?,
choice: ManualTargetChoice,
directKgText: String,
percentResult: ManualPercentMaxResult?,
equipmentSemantics: EquipmentLoadSemantics?,
): Result<SessionExercisePlan?> {
if (choice == ManualTargetChoice.NONE) return Result.success(null)
val weight = when (choice) {
ManualTargetChoice.KG -> directKgText.trim().replace(',', '.').toDoubleOrNull()
?.takeIf { it.isFinite() && it > 0.0 }
?: return Result.failure(IllegalArgumentException(
"Saisissez une charge cible strictement positive."))
ManualTargetChoice.PERCENT_MAX ->
(percentResult as? ManualPercentMaxResult.Available)?.targetWeightKg
?: return Result.failure(IllegalArgumentException(
(percentResult as? ManualPercentMaxResult.Unavailable)?.message
?: "MAX compatible indisponible."))
ManualTargetChoice.NONE -> error("handled above")
}
val mode = when (equipmentSemantics) {
EquipmentLoadSemantics.EXTERNAL -> SessionLoadMode.EXTERNAL
EquipmentLoadSemantics.ASSISTANCE -> if (choice == ManualTargetChoice.PERCENT_MAX)
return Result.failure(IllegalArgumentException(
"Le %MAX est indisponible pour une assistance ; choisissez une résistance externe."))
else SessionLoadMode.ASSISTANCE
else -> return Result.failure(IllegalArgumentException(
"Choisissez un équipement compatible avec une charge cible."))
}
val dose = existing ?: when (draft.exercise.trackingMode) {
TrackingMode.REPS -> SessionExercisePlan(
sets = draft.sets.size,
reps = draft.sets.firstOrNull()?.reps,
)
TrackingMode.DURATION -> SessionExercisePlan(
sets = draft.sets.size,
durationSeconds = draft.sets.firstOrNull()?.durationSeconds,
)
}
if (dose.sets !in 1..MAX_SESSION_SETS ||
(draft.exercise.trackingMode == TrackingMode.REPS &&
(dose.reps == null || dose.reps !in 1..MAX_REPS_PER_SET)) ||
(draft.exercise.trackingMode == TrackingMode.DURATION &&
(dose.durationSeconds == null || dose.durationSeconds <= 0)))
return Result.failure(IllegalArgumentException(
"Définissez une dose cible positive avant la charge cible."))
return Result.success(dose.copy(weightKg = weight, loadMode = mode))
}
@Composable
private fun SessionExerciseForm(
key: String,
@ -711,6 +766,7 @@ private fun SessionExerciseForm(
exercise: ExerciseProfile,
sessionType: SessionType,
initialForm: SessionDraftForm,
initialPlan: SessionExercisePlan?,
onFormChanged: (SessionDraftForm) -> Unit,
onCancel: () -> Unit,
onAdd:
@ -790,6 +846,14 @@ private fun SessionExerciseForm(
var selectedEquipmentId by remember(key) {
mutableStateOf(initialForm.selectedEquipmentId)
}
var targetChoice by remember(key) {
mutableStateOf(if (initialPlan?.weightKg != null) ManualTargetChoice.KG
else ManualTargetChoice.NONE)
}
var targetKgText by remember(key) {
mutableStateOf(initialPlan?.weightKg?.let(::formatMaxWeight).orEmpty())
}
var targetPercentText by remember(key) { mutableStateOf("70") }
var error by
remember(key) {
@ -836,6 +900,17 @@ private fun SessionExerciseForm(
},
)
val selectedEquipment = equipmentEntries.firstOrNull { it.equipmentId == selectedEquipmentId }
val percentResult = remember(
exercise.exerciseId, selectedEquipmentId, targetPercentText,
targetChoice, equipmentRevision,
) {
if (targetChoice == ManualTargetChoice.PERCENT_MAX)
repository.calculateManualPercentMaxTarget(
exercise.exerciseId, selectedEquipmentId,
targetPercentText.toIntOrNull() ?: 0,
)
else null
}
if (selectedEquipment != null) {
TrainlogAction(
label = "${selectedEquipment.displayName}",
@ -883,6 +958,45 @@ private fun SessionExerciseForm(
exercise.recordingMode ==
RecordingMode.SETS
) {
TrainlogInfo("Charge cible prévue — séparée des charges réellement effectuées.", colors.muted)
TrainlogChoiceChips(
listOf(
ManualTargetChoice.KG.name to "Valeur en kg",
ManualTargetChoice.PERCENT_MAX.name to "% de mon MAX",
ManualTargetChoice.NONE.name to "Aucune",
),
targetChoice.name,
) { selected -> targetChoice = ManualTargetChoice.valueOf(selected) }
when (targetChoice) {
ManualTargetChoice.KG -> SessionNumberField(
label = "Charge cible (kg)", value = targetKgText,
onValueChange = { targetKgText = it; error = null },
)
ManualTargetChoice.PERCENT_MAX -> {
SessionNumberField(
label = "Pourcentage de mon MAX (1 à 100)",
value = targetPercentText,
onValueChange = { targetPercentText = it; error = null },
)
when (val result = percentResult) {
is ManualPercentMaxResult.Available -> {
TrainlogInfo(
"MAX compatible : ${formatMaxWeight(result.maxWeightKg)} kg · ${result.maxStartedAt}",
colors.muted,
)
TrainlogInfo(
"Cible calculée : ${formatMaxWeight(result.targetWeightKg)} kg",
colors.success,
)
}
is ManualPercentMaxResult.Unavailable ->
TrainlogInfo(result.message, colors.error)
null -> Unit
}
}
ManualTargetChoice.NONE ->
TrainlogInfo("Aucune charge cible ne sera enregistrée.", colors.muted)
}
if (
exercise.trackingMode ==
TrackingMode.REPS
@ -1153,8 +1267,18 @@ private fun SessionExerciseForm(
} else {
"Valeurs invalides."
}
} else if (sessionType == SessionType.TRAINING &&
exercise.recordingMode == RecordingMode.SETS) {
val plan = buildManualTargetPlan(
draft, initialPlan, targetChoice, targetKgText,
percentResult, selectedEquipment?.loadSemantics,
)
plan.fold(
onSuccess = { onAdd(draft.copy(plan = it)) },
onFailure = { error = it.message ?: "Charge cible invalide." },
)
} else {
onAdd(draft)
onAdd(draft.copy(plan = null))
}
},
)

View file

@ -54,37 +54,6 @@ fun SyncScreen(
)
}
LaunchedEffect(Unit) {
when (
val result =
inbox.importPcCatalog()
) {
is CatalogInboxResult.Imported -> {
if (
result.imported > 0 ||
result.reconciled > 0
) {
success = true
status =
(
"Catalogue PC appliqué automatiquement : " +
"${result.imported} nouveau(x), " +
"${result.reconciled} réconcilié(s)."
)
onCatalogChanged()
}
}
CatalogInboxResult.FolderNotAuthorized,
CatalogInboxResult.FileNotFound,
is CatalogInboxResult.Error -> {
/* Nothing to import yet. */
}
}
}
LaunchedEffect(
pendingRequestId
) {
@ -265,18 +234,10 @@ fun SyncScreen(
TrainlogScreen(
subtitle =
"S Y N C H R O N I S A T I O N"
"Synchronisation"
) {
TrainlogAction(
label = "< Retour",
description =
"Revenir à l'accueil.",
onClick = onBack,
accent = colors.muted,
)
TrainlogFrame(
title = "SYNCHRONISER"
title = "Synchroniser"
) {
TrainlogInfo(
text =
@ -430,7 +391,7 @@ fun SyncScreen(
if (status != null) {
TrainlogFrame(
title = "ETAT",
title = "État",
active = false,
) {
TrainlogInfo(

View file

@ -2,329 +2,134 @@ package com.labfytools.trainlog.ui
/* TRAINLOG_PC_CATALOG_AUTO_APPLY */
import androidx.activity.compose.BackHandler
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.getValue
import androidx.compose.runtime.setValue
import androidx.compose.runtime.saveable.rememberSaveableStateHolder
import com.labfytools.trainlog.data.ActiveDraftLoadResult
import com.labfytools.trainlog.data.ActiveDraftMutationResult
import com.labfytools.trainlog.data.TrainlogRepository
import com.labfytools.trainlog.data.SyncExporter
import com.labfytools.trainlog.data.CatalogInboxResult
import com.labfytools.trainlog.data.SyncCatalogInbox
import com.labfytools.trainlog.data.SyncExporter
import com.labfytools.trainlog.data.SyncRequestOutbox
private enum class TrainlogScreenId {
HOME,
SESSION,
SESSION_GENERATOR,
EXERCISE,
BODY,
HISTORY,
SESSION_DETAIL,
SYNC,
}
import com.labfytools.trainlog.data.TrainlogRepository
@Composable
fun TrainlogApp(
repository: TrainlogRepository,
exporter: SyncExporter,
inbox: SyncCatalogInbox,
requestOutbox: SyncRequestOutbox,
) {
var screen by
remember {
mutableStateOf(
TrainlogScreenId.HOME
)
}
var exerciseReturnTarget by
remember {
mutableStateOf(
TrainlogScreenId.HOME
)
}
var catalogRevision by
remember {
mutableIntStateOf(0)
}
var draftRevision by
remember {
mutableIntStateOf(0)
}
var draftMessage by
remember {
mutableStateOf<String?>(null)
}
var selectedSessionId by
remember {
mutableStateOf<String?>(
null
)
}
fun TrainlogApp(repository: TrainlogRepository, exporter: SyncExporter, inbox: SyncCatalogInbox, requestOutbox: SyncRequestOutbox, appState: TrainlogAppState) {
val navigation = appState.navigation
val generatorState = appState.generator
val equipmentState = appState.equipment
val exerciseState = appState.exercise
val bodyState = appState.body
var catalogRevision by remember { mutableIntStateOf(0) }
var draftRevision by remember { mutableIntStateOf(0) }
var draftMessage by remember { mutableStateOf<String?>(null) }
val navigationController = appState.navigationController
/* CONTRACT: automatic exchange work belongs to the application lifecycle.
* Re-entering a route must never trigger a second import or export. */
LaunchedEffect(Unit) {
when (
inbox.importPcCatalog()
) {
is CatalogInboxResult.Imported -> {
catalogRevision += 1
when (inbox.importPcCatalog()) {
is CatalogInboxResult.Imported -> catalogRevision++
CatalogInboxResult.FolderNotAuthorized, CatalogInboxResult.FileNotFound,
is CatalogInboxResult.Error -> Unit
}
CatalogInboxResult.FolderNotAuthorized,
CatalogInboxResult.FileNotFound,
is CatalogInboxResult.Error -> {
/* Nothing to import yet. */
}
}
}
LaunchedEffect(Unit) {
exporter.exportMobileBundle()
}
if (
screen !=
TrainlogScreenId.HOME
) {
BackHandler {
screen =
when (screen) {
TrainlogScreenId.EXERCISE ->
exerciseReturnTarget
val draftLoad = remember(draftRevision, catalogRevision) { repository.loadActiveSessionDraft() }
val activeDraft = (draftLoad as? ActiveDraftLoadResult.Loaded)?.draft
TrainlogScreenId.SESSION_DETAIL ->
TrainlogScreenId.HISTORY
else ->
TrainlogScreenId.HOME
}
}
}
when (screen) {
TrainlogScreenId.HOME -> {
val draftLoad =
remember(
draftRevision,
catalogRevision,
) {
repository.loadActiveSessionDraft()
}
HomeScreen(
activeDraft =
(draftLoad as?
ActiveDraftLoadResult.Loaded)
?.draft,
draftError =
draftMessage
?: (draftLoad as?
ActiveDraftLoadResult.Error)
?.message
?: (draftLoad as?
ActiveDraftLoadResult.Loaded)
?.warning,
onSession = {
fun open(route: AppRoute) { navigationController.open(route) }
fun openSection(section: AppSection) { navigationController.openSection(section) }
fun back(): Boolean = navigationController.back()
fun openManualSession() {
when (draftLoad) {
is ActiveDraftLoadResult.Loaded -> {
draftMessage = null
screen = TrainlogScreenId.SESSION
}
ActiveDraftLoadResult.None -> {
when (
val result =
repository
.startActiveSessionDraft()
) {
ActiveDraftMutationResult.Saved -> {
draftMessage = null
draftRevision += 1
screen =
TrainlogScreenId.SESSION
}
is ActiveDraftMutationResult.Error -> {
draftMessage = result.message
is ActiveDraftLoadResult.Loaded -> open(AppRoute.SessionEditor)
ActiveDraftLoadResult.None -> when (val result = repository.startActiveSessionDraft()) {
ActiveDraftMutationResult.Saved -> { draftRevision++; open(AppRoute.SessionEditor) }
is ActiveDraftMutationResult.Error -> draftMessage = result.message
}
is ActiveDraftLoadResult.Error -> draftMessage = draftLoad.message
}
}
is ActiveDraftLoadResult.Error -> {
draftMessage = draftLoad.message
}
}
},
onGenerateSession = {
draftMessage = null
screen = TrainlogScreenId.SESSION_GENERATOR
},
onDiscardDraft = {
when (
val result =
repository
.discardActiveSessionDraft()
) {
ActiveDraftMutationResult.Saved -> {
draftMessage = null
draftRevision += 1
val routeStateHolder = rememberSaveableStateHolder()
val retainedRouteKeys = remember { ArrayDeque<String>() }
val routeKey = navigation.route.stateKey()
LaunchedEffect(routeKey) {
retainedRouteKeys.remove(routeKey)
retainedRouteKeys.addLast(routeKey)
while (retainedRouteKeys.size > 16) routeStateHolder.removeState(retainedRouteKeys.removeFirst())
}
is ActiveDraftMutationResult.Error -> {
draftMessage = result.message
}
}
},
onExercise = {
exerciseReturnTarget =
TrainlogScreenId.HOME
screen =
TrainlogScreenId.EXERCISE
},
onBody = {
screen =
TrainlogScreenId.BODY
},
onHistory = {
screen =
TrainlogScreenId.HISTORY
},
onSync = {
screen =
TrainlogScreenId.SYNC
},
AndroidAppShell(navigation.route, ::openSection, ::back) {
routeStateHolder.SaveableStateProvider(routeKey) {
when (val route = navigation.route) {
AppRoute.Home -> HomeScreen(
activeDraft, draftMessage ?: (draftLoad as? ActiveDraftLoadResult.Error)?.message ?: (draftLoad as? ActiveDraftLoadResult.Loaded)?.warning,
repository.listSessions().firstOrNull(), repository.listLatestExerciseMaxima().firstOrNull(),
::openManualSession, { open(AppRoute.SessionGenerator) },
{ open(AppRoute.BodyMeasurements) }, { open(AppRoute.SessionDetail(it)) }, { open(AppRoute.Sync) },
)
AppRoute.Sessions -> SessionsHub(activeDraft, { open(AppRoute.SessionEditor) }, { open(AppRoute.SessionGenerator) }, ::openManualSession, { open(AppRoute.CompletedSessions) })
AppRoute.SessionEditor -> SessionScreen(repository, catalogRevision, { draftRevision++; back() }, { open(AppRoute.ExerciseCreate(AppRoute.SessionEditor)) }, { exporter.exportMobileBundle(); draftRevision++ })
AppRoute.SessionGenerator -> SessionGeneratorScreen(repository, generatorState, { back() }, { generatorState.abandon(); draftRevision++; open(AppRoute.SessionEditor) }, {
draftMessage = "Une séance est déjà en cours. Reprenez-la ou revenez à la proposition conservée."
draftRevision++
/* WHY: ExistingActiveDraft is discovered only after the accept
* action, while the proposal is still dirty. CONTRACT: this
* route request uses the same owner and keep/discard guard as
* Back and drawer navigation. */
openSection(AppSection.SESSIONS)
})
AppRoute.CompletedSessions -> HistoryScreen(repository, { back() }) { open(AppRoute.SessionDetail(it)) }
is AppRoute.SessionDetail -> SessionDetailScreen(repository, route.sessionId, { back() }) { draftRevision++; open(AppRoute.SessionEditor) }
AppRoute.Exercises -> ExerciseCatalogueScreen(repository, exerciseState, { open(AppRoute.ExerciseCreate(AppRoute.Exercises)) }, { open(AppRoute.ExerciseDetail(it)) })
is AppRoute.ExerciseDetail -> ExerciseDetailScreen(repository, route.exerciseId, { open(AppRoute.ExerciseEdit(route.exerciseId, route)) }, { open(AppRoute.LatestMaxima) })
is AppRoute.ExerciseCreate -> ExerciseEditorRoute(repository, exerciseState, null, route.caller == AppRoute.SessionEditor) { exporter.exportMobileBundle(); catalogRevision++; back() }
is AppRoute.ExerciseEdit -> ExerciseEditorRoute(repository, exerciseState, route.exerciseId, false) { exporter.exportMobileBundle(); catalogRevision++; back() }
AppRoute.Equipment -> EquipmentScreen(repository, equipmentState, { open(AppRoute.EquipmentCreate(AppRoute.Equipment)) }, { open(AppRoute.EquipmentDetail(it)) })
is AppRoute.EquipmentDetail -> EquipmentDetailScreen(repository, route.equipmentId)
is AppRoute.EquipmentCreate -> EquipmentCreateScreen(repository, equipmentState) { exporter.exportMobileBundle(); catalogRevision++; back() }
AppRoute.Statistics -> StatisticsHub({ open(AppRoute.BodyMeasurements) }, { open(AppRoute.LatestMaxima) })
AppRoute.BodyMeasurements -> BodyScreen(repository, bodyState, { exporter.exportMobileBundle() }, { back() })
AppRoute.LatestMaxima -> LatestMaximaScreen(repository)
AppRoute.Sync -> SyncScreen(inbox, requestOutbox, { exporter.exportMobileBundle(); catalogRevision++ }, { back() })
AppRoute.Settings -> SettingsScreen(inbox) { exporter.exportMobileBundle(); catalogRevision++ }
}}
}
TrainlogScreenId.SESSION ->
SessionScreen(
repository = repository,
catalogRevision =
catalogRevision,
onBack = {
/* WHY: Back changes routing only; the repository remains
* the canonical owner of the in-progress workout. */
draftRevision += 1
screen =
TrainlogScreenId.HOME
},
onCreateExercise = {
exerciseReturnTarget =
TrainlogScreenId.SESSION
screen =
TrainlogScreenId.EXERCISE
},
onSessionSaved = {
exporter.exportMobileBundle()
draftRevision += 1
},
)
TrainlogScreenId.SESSION_GENERATOR ->
SessionGeneratorScreen(
repository = repository,
onBack = { screen = TrainlogScreenId.HOME },
onAccepted = {
draftRevision += 1
screen = TrainlogScreenId.SESSION
},
onExistingDraft = {
draftMessage = "Une séance est déjà en cours. Reprenez-la ou supprimez-la explicitement depuis l'accueil."
draftRevision += 1
screen = TrainlogScreenId.HOME
},
)
TrainlogScreenId.EXERCISE ->
ExerciseScreen(
repository = repository,
inline =
exerciseReturnTarget ==
TrainlogScreenId.SESSION,
onBack = {
screen =
exerciseReturnTarget
},
onSaved = {
exporter.exportMobileBundle()
catalogRevision += 1
screen =
exerciseReturnTarget
},
)
TrainlogScreenId.BODY ->
BodyScreen(
repository = repository,
onBodySaved = {
exporter.exportMobileBundle()
},
onBack = {
screen =
TrainlogScreenId.HOME
},
)
TrainlogScreenId.HISTORY ->
HistoryScreen(
repository = repository,
onBack = {
screen =
TrainlogScreenId.HOME
},
onOpenSession = {
sessionId ->
selectedSessionId =
sessionId
screen =
TrainlogScreenId.SESSION_DETAIL
},
)
TrainlogScreenId.SESSION_DETAIL ->
SessionDetailScreen(
repository = repository,
sessionId =
selectedSessionId,
onBack = {
screen =
TrainlogScreenId.HISTORY
},
onResumeMaxTest = {
draftRevision += 1
screen = TrainlogScreenId.SESSION
},
)
TrainlogScreenId.SYNC ->
SyncScreen(
inbox = inbox,
requestOutbox =
requestOutbox,
onCatalogChanged = {
exporter.exportMobileBundle()
catalogRevision += 1
},
onBack = {
screen =
TrainlogScreenId.HOME
if (navigationController.hasPendingNavigation) {
val guardedRoute = navigationController.pendingRoute
val generator = guardedRoute == AppRoute.SessionGenerator
AlertDialog(
onDismissRequest = navigationController::cancelPending,
title = { Text(if (generator) "Proposition non acceptée" else "Modifications non enregistrées") },
text = { Text(if (generator) "La proposition peut rester en mémoire pendant que vous changez de rubrique." else "Les champs restent en mémoire tant que vous ne les abandonnez pas explicitement.") },
confirmButton = { TextButton(onClick = navigationController::keepAndNavigate) { Text("Conserver et quitter") } },
dismissButton = {
TextButton(onClick = {
navigationController.discardAndNavigate()
}) { Text(if (generator) "Abandonner la proposition" else "Abandonner les modifications") }
},
)
}
}
private fun AppRoute.stateKey(): String = when (this) {
is AppRoute.SessionDetail -> "session:${sessionId}"
is AppRoute.ExerciseDetail -> "exercise:${exerciseId}"
is AppRoute.ExerciseEdit -> "exercise-edit:${exerciseId}:${caller.section}"
is AppRoute.ExerciseCreate -> "exercise-create:${caller.section}"
is AppRoute.EquipmentDetail -> "equipment:${equipmentId}"
is AppRoute.EquipmentCreate -> "equipment-create:${caller.section}"
else -> this::class.qualifiedName.orEmpty()
}

View file

@ -2,15 +2,17 @@ package com.labfytools.trainlog.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
@ -21,6 +23,9 @@ import androidx.compose.foundation.text.BasicText
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.FilterChip
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
@ -32,14 +37,15 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.labfytools.trainlog.ui.theme.LocalTrainlogColors
import com.labfytools.trainlog.ui.theme.TrainlogTypography
@Composable
fun TrainlogScreen(
subtitle: String,
scrollKey: String = subtitle,
content: @Composable ColumnScope.() -> Unit,
) {
val colors =
@ -49,8 +55,6 @@ fun TrainlogScreen(
modifier =
Modifier
.background(colors.background)
.statusBarsPadding()
.navigationBarsPadding()
.imePadding()
.verticalScroll(
rememberScrollState()
@ -62,51 +66,16 @@ fun TrainlogScreen(
)
),
) {
TrainlogBanner(
subtitle = subtitle
BasicText(
text = subtitle.lowercase().replaceFirstChar { it.titlecase() },
modifier = Modifier.padding(bottom = 18.dp),
style = TrainlogTypography.title.copy(color = colors.text),
)
content()
}
}
@Composable
private fun TrainlogBanner(
subtitle: String
) {
val colors =
LocalTrainlogColors.current
Column(
modifier =
Modifier
.fillMaxWidth()
.padding(bottom = 18.dp)
) {
/* WHY: TUI and Android share this compact plaque rather than separate
* brand treatments. The terminal box becomes flat spacing on touch. */
BasicText(
text = "◆ TRAINLOG ◆",
style =
TrainlogTypography.banner.copy(
color = colors.accent,
fontWeight = FontWeight.Bold,
fontSize = 21.sp,
),
)
BasicText(
text = subtitle,
modifier = Modifier.padding(top = 5.dp),
style =
TrainlogTypography.small.copy(
color = colors.muted,
fontWeight = FontWeight.Bold,
),
)
}
}
@Composable
fun TrainlogFrame(
title: String,
@ -119,7 +88,7 @@ fun TrainlogFrame(
val accent =
if (active) {
colors.warning
colors.accent
} else {
colors.muted
}
@ -131,7 +100,7 @@ fun TrainlogFrame(
.padding(bottom = 16.dp)
) {
BasicText(
text = title.uppercase(),
text = title,
style =
TrainlogTypography.small.copy(
color = accent,
@ -149,7 +118,7 @@ fun TrainlogFrame(
bottom = 8.dp,
)
.height(1.dp)
.background(accent)
.background(colors.surfaceAlt)
)
content()
@ -175,18 +144,11 @@ fun TrainlogAction(
modifier
.fillMaxWidth()
.padding(vertical = 3.dp)
.heightIn(min = 48.dp)
.height(IntrinsicSize.Min)
.background(colors.surface)
.background(Color.Transparent)
.clickable(onClick = onClick)
) {
Box(
modifier =
Modifier
.width(3.dp)
.fillMaxHeight()
.background(actualAccent)
)
Column(
modifier =
Modifier.padding(
@ -219,6 +181,45 @@ fun TrainlogAction(
}
}
@Composable
fun TrainlogPrimaryAction(label: String, description: String, onClick: () -> Unit) {
val colors = LocalTrainlogColors.current
Column(Modifier.fillMaxWidth().padding(bottom = 12.dp)) {
Button(onClick = onClick, modifier = Modifier.fillMaxWidth().heightIn(min = 48.dp)) {
Text(label)
}
if (description.isNotBlank()) {
BasicText(
description,
Modifier.padding(top = 4.dp),
TrainlogTypography.small.copy(color = colors.muted),
)
}
}
}
/** Compact horizontally scrollable choices; selection is textual and colored. */
@Composable
fun TrainlogChoiceChips(
choices: List<Pair<String, String>>,
selectedId: String,
onSelected: (String) -> Unit,
) {
Row(
Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
choices.forEach { (id, label) ->
FilterChip(
selected = id == selectedId,
onClick = { onSelected(id) },
label = { Text(if (id == selectedId) "$label" else label) },
modifier = Modifier.heightIn(min = 48.dp),
)
}
}
}
@Composable
fun TrainlogInputField(
label: String,
@ -269,12 +270,18 @@ fun TrainlogInputField(
cursorBrush =
SolidColor(colors.accent),
textStyle =
TrainlogTypography.normal.copy(
(if (keyboardOptions.keyboardType == KeyboardType.Number ||
keyboardOptions.keyboardType == KeyboardType.Decimal) {
TrainlogTypography.numeric
} else {
TrainlogTypography.normal
}).copy(
color = colors.text,
),
modifier =
Modifier
.fillMaxWidth()
.heightIn(min = 48.dp)
.onFocusChanged {
focused = it.isFocused
}

View file

@ -3,9 +3,12 @@ package com.labfytools.trainlog.ui.theme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
data class TrainlogColors(
@ -19,20 +22,30 @@ data class TrainlogColors(
val error: Color,
val muted: Color,
val graph: Color,
val crust: Color,
val mantle: Color,
val lavender: Color,
val info: Color,
val notice: Color,
)
private val TrainlogDarkColors =
TrainlogColors(
background = Color(0xFF1E1E2E),
surface = Color(0xFF181825),
surfaceAlt = Color(0xFF313244),
surface = Color(0xFF313244),
surfaceAlt = Color(0xFF45475A),
text = Color(0xFFCDD6F4),
accent = Color(0xFF94E2D5),
accent = Color(0xFFB4BEFE),
success = Color(0xFFA6E3A1),
warning = Color(0xFFF9E2AF),
error = Color(0xFFF38BA8),
muted = Color(0xFF89B4FA),
muted = Color(0xFFBAC2DE),
graph = Color(0xFFF5C2E7),
crust = Color(0xFF11111B),
mantle = Color(0xFF181825),
lavender = Color(0xFFB4BEFE),
info = Color(0xFF89B4FA),
notice = Color(0xFFFAB387),
)
val LocalTrainlogColors =
@ -43,36 +56,55 @@ val LocalTrainlogColors =
object TrainlogTypography {
val normal =
TextStyle(
fontFamily = FontFamily.Monospace,
fontSize = 15.sp,
fontFamily = FontFamily.Default,
fontSize = 16.sp,
lineHeight = 24.sp,
)
val small =
TextStyle(
fontFamily = FontFamily.Monospace,
fontSize = 12.sp,
fontFamily = FontFamily.Default,
fontSize = 14.sp,
lineHeight = 20.sp,
)
val title =
TextStyle(
fontFamily = FontFamily.Monospace,
fontSize = 19.sp,
fontFamily = FontFamily.Default,
fontSize = 22.sp,
lineHeight = 28.sp,
fontWeight = FontWeight.SemiBold,
)
val banner =
TextStyle(
fontFamily = FontFamily.Monospace,
fontSize = 22.sp,
fontFamily = FontFamily.Default,
fontSize = 18.sp,
lineHeight = 24.sp,
fontWeight = FontWeight.SemiBold,
)
val section = TextStyle(fontFamily = FontFamily.Default, fontSize = 16.sp, lineHeight = 22.sp, fontWeight = FontWeight.SemiBold)
val value = TextStyle(fontFamily = FontFamily.Default, fontSize = 24.sp, lineHeight = 30.sp, fontWeight = FontWeight.Medium, fontFeatureSettings = "tnum")
val numeric = TextStyle(fontFamily = FontFamily.Monospace, fontSize = 16.sp, lineHeight = 24.sp)
}
@Composable
fun TrainlogTheme(
content: @Composable () -> Unit
) {
CompositionLocalProvider(
LocalTrainlogColors provides
TrainlogDarkColors,
content = content,
val scheme = darkColorScheme(
primary = TrainlogDarkColors.lavender,
onPrimary = TrainlogDarkColors.crust,
background = TrainlogDarkColors.background,
onBackground = TrainlogDarkColors.text,
surface = TrainlogDarkColors.surface,
onSurface = TrainlogDarkColors.text,
surfaceVariant = TrainlogDarkColors.surfaceAlt,
onSurfaceVariant = TrainlogDarkColors.muted,
error = TrainlogDarkColors.error,
)
CompositionLocalProvider(LocalTrainlogColors provides TrainlogDarkColors) {
MaterialTheme(colorScheme = scheme, content = content)
}
}

View file

@ -0,0 +1 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24"><path android:fillColor="#CDD6F4" android:pathData="M20,11H7.83l5.59,-5.59L12,4l-8,8 8,8 1.42,-1.41L7.83,13H20z"/></vector>

View file

@ -0,0 +1 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24"><path android:fillColor="#CDD6F4" android:pathData="M4,4h16v4H4zM6,9h3v11H6zM15,9h3v11h-3zM9,12h6v3H9z"/></vector>

View file

@ -0,0 +1 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24"><path android:fillColor="#CDD6F4" android:pathData="M3,9h3v6H3zM7,7h3v10H7zM14,7h3v10h-3zM18,9h3v6h-3zM10,11h4v2h-4z"/></vector>

View file

@ -0,0 +1 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24"><path android:fillColor="#CDD6F4" android:pathData="M12,3 2,12h3v9h6v-6h2v6h6v-9h3z"/></vector>

View file

@ -0,0 +1 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24"><path android:fillColor="#CDD6F4" android:pathData="M3,6h18v2H3zM3,11h18v2H3zM3,16h18v2H3z"/></vector>

View file

@ -0,0 +1 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24"><path android:fillColor="#CDD6F4" android:pathData="M7,3h10v2h3v16H4V5h3zM6,7v12h12V7h-2v2H8V7z"/></vector>

View file

@ -0,0 +1 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24"><path android:fillColor="#CDD6F4" android:pathData="M10,2h4l1,3 3,-1 2,3 -2,2 1,3 -1,3 2,2 -2,3 -3,-1 -1,3h-4l-1,-3 -3,1 -2,-3 2,-2 -1,-3 1,-3 -2,-2 2,-3 3,1zM12,8a4,4 0,1 0,0 8,4 4,0 0,0 0,-8z"/></vector>

View file

@ -0,0 +1 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24"><path android:fillColor="#CDD6F4" android:pathData="M4,13h4v7H4zM10,8h4v12h-4zM16,4h4v16h-4z"/></vector>

View file

@ -0,0 +1 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24"><path android:fillColor="#CDD6F4" android:pathData="M7,7h9l-3,-3 1.4,-1.4L20,8l-5.6,5.4L13,12l3,-3H7a4,4 0,0 0,-4,4H1a6,6 0,0 1,6,-6zM17,17H8l3,3 -1.4,1.4L4,16l5.6,-5.4L11,12l-3,3h9a4,4 0,0 0,4,-4h2a6,6 0,0 1,-6,6z"/></vector>

View file

@ -61,7 +61,7 @@ class AndroidV10PlanningMigrationTest {
TrainlogRepository(context, name).useForTest { it.listSessions() }
SQLiteDatabase.openDatabase(path.path, null, SQLiteDatabase.OPEN_READONLY).use { db ->
assertEquals(11, scalar(db, "PRAGMA user_version"))
assertEquals(12, scalar(db, "PRAGMA user_version"))
for (table in listOf("session_exercises", "draft_session_exercises")) {
assertEquals(6, scalar(db, "SELECT COUNT(*) FROM pragma_table_info('$table') WHERE name IN ('load_mode','rest_seconds','target_sets','target_reps','target_duration_seconds','target_weight_kg')"))
assertEquals(0, scalar(db, "SELECT COUNT(*) FROM $table WHERE load_mode<>'none' OR rest_seconds<>0 OR target_sets IS NOT NULL OR target_reps IS NOT NULL OR target_duration_seconds IS NOT NULL OR target_weight_kg IS NOT NULL"))

View file

@ -0,0 +1,103 @@
package com.labfytools.trainlog.data
import android.content.Context
import android.database.Cursor
import android.database.sqlite.SQLiteDatabase
import androidx.test.core.app.ApplicationProvider
import com.labfytools.trainlog.model.BodyObservationDraft
import com.labfytools.trainlog.model.NewExerciseProfile
import com.labfytools.trainlog.model.RecordingMode
import com.labfytools.trainlog.model.SessionDraft
import com.labfytools.trainlog.model.SessionExerciseDraft
import com.labfytools.trainlog.model.SessionSetDraft
import com.labfytools.trainlog.model.TrackingMode
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import java.util.UUID
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [35])
class AndroidV11ExerciseAliasMigrationTest {
private lateinit var context: Context
private lateinit var name: String
@Before fun setUp() {
context = ApplicationProvider.getApplicationContext()
name = "exercise-alias-v11-${UUID.randomUUID()}.db"
}
@After fun tearDown() { context.deleteDatabase(name) }
@Test fun exactVersionElevenFixtureOnlyAddsEmptyAliasTable() {
val repository = TrainlogRepository(context, name)
val created = repository.createExercise(NewExerciseProfile(
"V11 fixture", RecordingMode.SETS, TrackingMode.REPS, 0,
)) as CreateExerciseResult.Created
assertTrue(repository.saveSession(SessionDraft(listOf(SessionExerciseDraft(
entryId = "sxe_11111111-1111-4111-8111-111111111111",
exercise = created.exercise,
equipmentId = "leg_press",
sets = listOf(SessionSetDraft(reps = 7, weightKg = 42.5)),
)))) is SaveSessionResult.Saved)
assertTrue(repository.saveBodyObservation(BodyObservationDraft(
bodyWeightKg = 72.25,
)) is SaveBodyObservationResult.Saved)
repository.close()
val path = context.getDatabasePath(name).path
lateinit var before: Map<String, List<List<Any?>>>
SQLiteDatabase.openDatabase(path, null, SQLiteDatabase.OPEN_READWRITE).use { db ->
db.execSQL("DROP TABLE exercise_aliases")
db.execSQL("PRAGMA user_version=11")
before = snapshot(db)
}
TrainlogRepository(context, name).let { migrated ->
migrated.listSessions()
migrated.close()
}
SQLiteDatabase.openDatabase(path, null, SQLiteDatabase.OPEN_READONLY).use { db ->
assertEquals(12, scalar(db, "PRAGMA user_version"))
assertEquals(before, snapshot(db).filterKeys { it != "exercise_aliases" })
assertEquals(0, scalar(db, "SELECT COUNT(*) FROM exercise_aliases"))
db.rawQuery("PRAGMA foreign_key_check", null).use { assertFalse(it.moveToFirst()) }
}
}
private fun snapshot(db: SQLiteDatabase): Map<String, List<List<Any?>>> {
val tables = db.rawQuery(
"SELECT name FROM sqlite_master WHERE type='table' " +
"AND name NOT LIKE 'sqlite_%' AND name<>'android_metadata' ORDER BY name",
null,
).use { cursor -> buildList { while (cursor.moveToNext()) add(cursor.getString(0)) } }
return tables.associateWith { table ->
db.rawQuery("SELECT * FROM `$table` ORDER BY rowid", null).use { cursor ->
buildList {
while (cursor.moveToNext()) add(List(cursor.columnCount) { column -> cursor.value(column) })
}
}
}
}
private fun Cursor.value(column: Int): Any? = when (getType(column)) {
Cursor.FIELD_TYPE_NULL -> null
Cursor.FIELD_TYPE_INTEGER -> getLong(column)
Cursor.FIELD_TYPE_FLOAT -> getDouble(column)
Cursor.FIELD_TYPE_STRING -> getString(column)
Cursor.FIELD_TYPE_BLOB -> getBlob(column).toList()
else -> error("Unsupported SQLite value type")
}
private fun scalar(db: SQLiteDatabase, sql: String): Int =
db.rawQuery(sql, null).use { cursor ->
assertTrue(cursor.moveToFirst())
cursor.getInt(0)
}
}

View file

@ -321,7 +321,7 @@ class BodyZonesTest {
SQLiteDatabase.openDatabase(context.getDatabasePath(name).path, null,
SQLiteDatabase.OPEN_READONLY).use { db ->
db.rawQuery("PRAGMA user_version", null).use { cursor ->
assertTrue(cursor.moveToFirst()); assertEquals(11, cursor.getInt(0))
assertTrue(cursor.moveToFirst()); assertEquals(12, cursor.getInt(0))
}
db.rawQuery("SELECT id FROM exercises", null).use { cursor ->
assertTrue(cursor.moveToFirst()); assertEquals(42L, cursor.getLong(0))

View file

@ -0,0 +1,32 @@
package com.labfytools.trainlog.data
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
class PercentMaxCalculatorTest {
private fun maximum(
equipmentId: String? = "leg_press",
semantics: EquipmentLoadSemantics? = EquipmentLoadSemantics.EXTERNAL,
) = ExplicitMaxContext(
sessionId = "se_test", entryId = "sxe_test", startedAt = "2026-09-10T08:00:00Z",
maxWeightKg = 137.5, equipmentId = equipmentId,
equipmentDisplayName = "Presse", loadSemantics = semantics,
)
@Test
fun exactExternalContextUsesUnroundedFormula() {
assertEquals(100.375,
PercentMaxCalculator.calculate(maximum(), "leg_press", 73)!!, 0.0000001)
}
@Test
fun incompatibleMissingAssistanceAndBoundsAreUnavailable() {
assertNull(PercentMaxCalculator.calculate(maximum(), "other", 73))
assertNull(PercentMaxCalculator.calculate(
maximum(semantics = EquipmentLoadSemantics.ASSISTANCE), "leg_press", 73))
assertNull(PercentMaxCalculator.calculate(maximum(equipmentId = null), "leg_press", 73))
assertNull(PercentMaxCalculator.calculate(maximum(), "leg_press", 0))
assertNull(PercentMaxCalculator.calculate(maximum(), "leg_press", 101))
}
}

View file

@ -71,7 +71,7 @@ class RealAndroidV9BodyZonesMigrationTest {
SQLiteDatabase.openDatabase(
migratedPath.path, null, SQLiteDatabase.OPEN_READWRITE,
).use { migrated ->
assertEquals(11, scalarInt(migrated, "PRAGMA user_version;"))
assertEquals(12, scalarInt(migrated, "PRAGMA user_version;"))
assertEquals("ok", scalarString(migrated, "PRAGMA integrity_check;"))
migrated.rawQuery("PRAGMA foreign_key_check;", null).use {
assertFalse(it.moveToFirst())

View file

@ -90,6 +90,45 @@ class SessionGeneratorRepositoryTest {
)
}
@Test
fun manualPercentMaxCalculationIsExactContextAndReadOnly() {
val repo = openRepository()
val exercise = exactExercise(repo)
assertTrue(repo.saveSession(SessionDraft(
exercises = listOf(SessionExerciseDraft(
exercise = exercise,
equipmentId = EQUIPMENT_ID,
maxWeightKg = 137.5,
)),
sessionType = com.labfytools.trainlog.model.SessionType.MAX_TEST,
)) is SaveSessionResult.Saved)
val before = mutableTableCounts()
val draftBefore = repo.loadActiveSessionDraft()
val result = repo.calculateManualPercentMaxTarget(
exercise.exerciseId, EQUIPMENT_ID, 73)
assertTrue(result is ManualPercentMaxResult.Available)
result as ManualPercentMaxResult.Available
assertEquals(137.5, result.maxWeightKg, 0.0)
assertEquals(100.375, result.targetWeightKg, 0.0000001)
assertEquals(before, mutableTableCounts())
assertEquals(draftBefore, repo.loadActiveSessionDraft())
assertTrue(repo.calculateManualPercentMaxTarget(
exercise.exerciseId, "plate_loaded_leg_press", 73,
) is ManualPercentMaxResult.Unavailable)
val assistance = repo.calculateManualPercentMaxTarget(
exercise.exerciseId, "assisted_dip_chin_machine", 73,
) as ManualPercentMaxResult.Unavailable
assertTrue(assistance.message.contains("assistance"))
val unknown = repo.calculateManualPercentMaxTarget(
exercise.exerciseId, "unknown_equipment", 73,
) as ManualPercentMaxResult.Unavailable
assertFalse(unknown.message.contains("assistance"))
assertTrue(unknown.message.contains("résistance externe"))
assertEquals(before, mutableTableCounts())
}
@Test
fun generationStreamsHistoryBeyondLegacyOccurrenceAndSetPreviewLimits() {
val repo = openRepository()

View file

@ -54,6 +54,109 @@ class TrainlogRepositoryDraftTest {
context.deleteDatabase(databaseName)
}
@Test
fun exerciseAliasCompanionMergesAndPreventsCatalogResurrection() {
val repo = openRepository()
val source = createExercise(repo, "Curl source", RecordingMode.SETS, TrackingMode.REPS)
val target = createExercise(repo, "Curl canonique", RecordingMode.SETS, TrackingMode.REPS)
val artifact = JSONObject()
.put("format", "trainlog-exercise-aliases")
.put("version", 1)
.put("aliases", org.json.JSONArray().put(JSONObject()
.put("source_exercise_id", source.exerciseId)
.put("canonical_exercise_id", target.exerciseId)))
.toString()
assertEquals(ExerciseAliasImportResult.Applied(1, 0),
repo.applyExerciseAliasesJson(artifact))
assertEquals(ExerciseAliasImportResult.Applied(0, 1),
repo.applyExerciseAliasesJson(artifact))
assertEquals(listOf(target.exerciseId), repo.listExercises().map { it.exerciseId })
val published = JSONObject(repo.buildExerciseAliasesJson())
assertEquals(source.exerciseId,
published.getJSONArray("aliases").getJSONObject(0).getString("source_exercise_id"))
val staleCatalog = JSONObject()
.put("format", "trainlog-pc-catalog")
.put("version", 1)
.put("exercises", org.json.JSONArray().put(JSONObject()
.put("exercise_id", source.exerciseId)
.put("name", "Retired stale label")
.put("recording_mode", "sets")
.put("tracking_mode", "reps")
.put("data_fields", 0)))
.toString()
assertTrue(repo.applyPcCatalogJson(staleCatalog) is PcCatalogImportResult.Applied)
assertEquals(listOf(target.exerciseId), repo.listExercises().map { it.exerciseId })
assertEquals("Curl canonique", repo.listExercises().single().name)
val canonicalCatalog = JSONObject(staleCatalog.toString())
canonicalCatalog.getJSONArray("exercises").getJSONObject(0)
.put("exercise_id", target.exerciseId)
.put("name", "Curl canonique renommé")
assertTrue(repo.applyPcCatalogJson(canonicalCatalog.toString()) is PcCatalogImportResult.Applied)
assertEquals("Curl canonique renommé", repo.listExercises().single().name)
val zones = JSONObject()
.put("format", "trainlog-exercise-body-zones")
.put("version", 1)
.put("generated_at", "2032-01-01T00:00:00+00:00")
.put("exercises", org.json.JSONArray().put(JSONObject()
.put("exercise_id", source.exerciseId)
.put("primary_zone_id", "arms")
.put("secondary_zone_ids", org.json.JSONArray().put("shoulders"))))
assertEquals(ExerciseBodyZoneImportResult.Applied(1, 0, 0),
repo.applyExerciseBodyZonesJson(zones.toString()))
assertEquals(ExerciseBodyZoneImportResult.Applied(0, 1, 0),
repo.applyExerciseBodyZonesJson(zones.toString()))
val canonicalWithZones = repo.listExercises().single()
assertEquals("arms", canonicalWithZones.primaryZoneId)
assertEquals(listOf("shoulders"), canonicalWithZones.secondaryZoneIds)
assertTrue(repo.saveSession(SessionDraft(listOf(SessionExerciseDraft(
entryId = "sxe_00000000-0000-4000-8000-000000000001", exercise = canonicalWithZones,
equipmentId = "leg_press", sets = listOf(SessionSetDraft(reps = 8, weightKg = 42.5)),
)))) is SaveSessionResult.Saved)
listOf(2, 3).forEach { version ->
val snapshot = JSONObject(if (version == 2) repo.buildMobileExportV2Json()
else repo.buildMobileExportV3Json())
val cloned = JSONObject(snapshot.getJSONArray("sessions").getJSONObject(0).toString())
val importedSessionId = "se_00000000-0000-4000-8000-00000000000$version"
val importedEntryId = "sxe_00000000-0000-4000-8000-00000000000$version"
cloned.put("session_id", importedSessionId)
cloned.getJSONArray("exercises").getJSONObject(0)
.put("entry_id", importedEntryId)
.put("exercise_id", source.exerciseId)
snapshot.getJSONArray("exercises").getJSONObject(0)
.put("exercise_id", source.exerciseId)
snapshot.put("sessions", org.json.JSONArray().put(cloned))
snapshot.put("body_observations", org.json.JSONArray())
val result = if (version == 2) repo.applyPcMobileExportV2Json(snapshot.toString())
else repo.applyPcMobileExportV3Json(snapshot.toString())
assertEquals(MobileSessionImportResult.Applied(1, 0, 0, 0), result)
val replay = if (version == 2) repo.applyPcMobileExportV2Json(snapshot.toString())
else repo.applyPcMobileExportV3Json(snapshot.toString())
assertEquals(MobileSessionImportResult.Applied(0, 1, 0, 0), replay)
val imported = repo.getSessionDetail(importedSessionId)!!.exercises.single()
assertEquals(target.exerciseId, imported.exerciseId)
assertEquals(listOf(8), imported.sets.map { it.reps })
assertEquals(listOf(42.5), imported.sets.map { it.weightKg })
assertEquals("leg_press", imported.equipmentId)
val associations = JSONObject(repo.buildEquipmentAssociationsJson())
val matching = (0 until associations.getJSONArray("associations").length())
.map { associations.getJSONArray("associations").getJSONObject(it) }
.single { it.getString("entry_id") == importedEntryId }
matching.put("exercise_id", source.exerciseId)
associations.put("associations", org.json.JSONArray().put(matching))
assertEquals(EquipmentAssociationImportResult.Applied(0),
repo.applyPcEquipmentAssociationsJson(associations.toString()))
}
val duplicated = artifact.replaceFirst("\"format\":", "\"format\":\"bad\",\"format\":")
assertTrue(repo.applyExerciseAliasesJson(duplicated) is ExerciseAliasImportResult.Invalid)
}
@Test
fun durableDraftRestoresEveryExerciseShapeAndRawForm() {
val first = openRepository()
@ -1505,7 +1608,7 @@ class TrainlogRepositoryDraftTest {
).use { db ->
db.rawQuery("PRAGMA user_version;", null).use { cursor ->
assertTrue(cursor.moveToFirst())
assertEquals(11, cursor.getInt(0))
assertEquals(12, cursor.getInt(0))
}
db.rawQuery(
"SELECT eq.equipment_id, ps.reps, ps.weight_kg FROM session_exercises se " +
@ -1632,7 +1735,7 @@ class TrainlogRepositoryDraftTest {
).use { db ->
db.rawQuery("PRAGMA user_version;", null).use { cursor ->
assertTrue(cursor.moveToFirst())
assertEquals(11, cursor.getInt(0))
assertEquals(12, cursor.getInt(0))
}
db.rawQuery("SELECT weight_kg FROM performed_sets WHERE id = 1;", null).use { cursor ->
assertTrue(cursor.moveToFirst())
@ -1788,6 +1891,54 @@ class TrainlogRepositoryDraftTest {
assertEquals(sessionId, loadDraft(reopened).sourceSessionId)
}
@Test
fun aggregateLatestMaxUsesExactInstantAndStableIdsWithoutDuplicates() {
val repo = openRepository()
val exercise = createExercise(repo, "Maximum ordering", RecordingMode.SETS, TrackingMode.REPS)
listOf(
Triple("sxe_offset", 100.0, "2026-09-23T10:00:00+15:00"),
Triple("sxe_tie_a", 110.0, "2026-09-22T20:00:00Z"),
Triple("sxe_tie_b", 120.0, "2026-09-22T21:00:00+01:00"),
).forEach { (entryId, weight, _) ->
assertTrue(repo.saveSession(SessionDraft(
exercises = listOf(SessionExerciseDraft(
entryId = entryId, exercise = exercise, maxWeightKg = weight,
)),
sessionType = SessionType.MAX_TEST,
)) is SaveSessionResult.Saved)
}
val byWeight = repo.listSessions().associate { summary ->
repo.getSessionDetail(summary.sessionId)!!.exercises.single().maxWeightKg!! to summary.sessionId
}
val stableIds = mapOf(
100.0 to "se_10000000-0000-4000-8000-000000000000",
110.0 to "se_20000000-0000-4000-8000-000000000000",
120.0 to "se_30000000-0000-4000-8000-000000000000",
)
val timestamps = mapOf(
100.0 to "2026-09-23T10:00:00+15:00",
110.0 to "2026-09-22T20:00:00Z",
120.0 to "2026-09-22T21:00:00+01:00",
)
SQLiteDatabase.openDatabase(
context.getDatabasePath(databaseName).absolutePath, null, SQLiteDatabase.OPEN_READWRITE,
).use { db ->
byWeight.forEach { (weight, generatedId) ->
db.execSQL(
"UPDATE sessions SET session_id=?,started_at=? WHERE session_id=?",
arrayOf(stableIds.getValue(weight), timestamps.getValue(weight), generatedId),
)
}
}
val maxima = repo.listLatestExerciseMaxima()
assertEquals(1, maxima.size)
assertEquals(exercise.exerciseId, maxima.single().exerciseId)
assertEquals(120.0, maxima.single().maxWeightKg, 0.0)
assertEquals("2026-09-22T21:00:00+01:00", maxima.single().startedAt)
}
@Test
fun versionNineConvertsOnlyUnambiguousLegacyMaxEntries() {
val initial = openRepository()
@ -2110,6 +2261,41 @@ class TrainlogRepositoryDraftTest {
assertEquals(3, exported.getJSONArray("sets").length())
}
@Test
fun exerciseAliasCompanionRekeysHistoryAndDraftAndReplaysIdempotently() {
val repo = openRepository()
val source = createExercise(repo, "Alias source", RecordingMode.SETS, TrackingMode.REPS)
val target = createExercise(repo, "Alias target", RecordingMode.SETS, TrackingMode.REPS)
assertTrue(repo.saveSession(SessionDraft(listOf(SessionExerciseDraft(
entryId = "sxe_alias_history", exercise = source,
sets = listOf(SessionSetDraft(reps = 8)),
)))) is SaveSessionResult.Saved)
assertEquals(ActiveDraftMutationResult.Saved, repo.saveActiveSessionDraft(
ActiveSessionDraft(exercises = listOf(SessionExerciseDraft(
entryId = "sxe_alias_draft", exercise = source,
sets = listOf(SessionSetDraft(reps = 6)),
))),
))
val artifact = JSONObject().put("format", "trainlog-exercise-aliases")
.put("version", 1).put("aliases", org.json.JSONArray().put(JSONObject()
.put("source_exercise_id", source.exerciseId)
.put("canonical_exercise_id", target.exerciseId))).toString()
assertEquals(ExerciseAliasImportResult.Applied(1, 0),
repo.applyExerciseAliasesJson(artifact))
assertEquals(target.exerciseId,
repo.getSessionDetail(repo.listSessions().single().sessionId)!!
.exercises.single().exerciseId)
assertEquals(target.exerciseId,
(repo.loadActiveSessionDraft() as ActiveDraftLoadResult.Loaded)
.draft.exercises.single().exercise.exerciseId)
val exported = JSONObject(repo.buildExerciseAliasesJson())
.getJSONArray("aliases").getJSONObject(0)
assertEquals(source.exerciseId, exported.getString("source_exercise_id"))
assertEquals(target.exerciseId, exported.getString("canonical_exercise_id"))
assertEquals(ExerciseAliasImportResult.Applied(0, 1),
repo.applyExerciseAliasesJson(artifact))
}
private fun openRepository(): TrainlogRepository {
return TrainlogRepository(context, databaseName).also { repository = it }
}

View file

@ -0,0 +1,170 @@
package com.labfytools.trainlog.ui
import android.content.Context
import androidx.test.core.app.ApplicationProvider
import com.labfytools.trainlog.data.BodyZoneRecentExposure
import com.labfytools.trainlog.data.ExposureWindowSummary
import com.labfytools.trainlog.data.GenerationWarningLevel
import com.labfytools.trainlog.data.SessionGenerationPreview
import com.labfytools.trainlog.data.SessionGenerationRequest
import com.labfytools.trainlog.data.TrainlogRepository
import java.security.MessageDigest
import java.util.UUID
import org.junit.After
import org.junit.Assert.*
import org.junit.Before
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 AppNavigationStateTest {
private lateinit var context: Context
private lateinit var databaseName: String
@Before fun setUp() {
context = ApplicationProvider.getApplicationContext()
databaseName = "app-navigation-${UUID.randomUUID()}.db"
}
@After fun tearDown() { context.deleteDatabase(databaseName) }
@Test fun sevenSectionsHaveTypedRootsAndSelectionComesFromRoute() {
assertEquals(7, AppSection.entries.size)
AppSection.entries.forEach { assertEquals(it, it.rootRoute().section) }
assertEquals(AppSection.SESSIONS, AppRoute.SessionDetail("se_1").section)
assertEquals(AppSection.EXERCISES, AppRoute.ExerciseDetail("ex_1").section)
assertEquals(AppSection.EQUIPMENT, AppRoute.EquipmentDetail("eq_1").section)
assertEquals(AppSection.STATISTICS, AppRoute.LatestMaxima.section)
}
@Test fun catalogueDetailsAndCreatorsReturnToTheirDeclaredCaller() {
val state = AppNavigationState(AppRoute.Exercises)
state.open(AppRoute.ExerciseDetail("ex_1"))
state.open(AppRoute.ExerciseEdit("ex_1", AppRoute.ExerciseDetail("ex_1")))
assertTrue(state.back()); assertEquals(AppRoute.ExerciseDetail("ex_1"), state.route)
assertTrue(state.back()); assertEquals(AppRoute.Exercises, state.route)
state.openSection(AppSection.EQUIPMENT)
state.open(AppRoute.EquipmentCreate(AppRoute.Equipment))
assertTrue(state.back()); assertEquals(AppRoute.Equipment, state.route)
}
@Test fun inlineCreationConsumesCallerHistoryBeforeFollowingBack() {
val state = AppNavigationState()
state.open(AppRoute.SessionEditor)
state.open(AppRoute.ExerciseCreate(AppRoute.SessionEditor))
assertTrue(state.back())
assertEquals(AppRoute.SessionEditor, state.route)
assertTrue(state.back())
assertEquals(AppRoute.Home, state.route)
assertFalse(state.back())
}
@Test fun productionGuardKeepsCompleteGeneratorStateAcrossDrawerNavigation() {
val f = fixture(AppRoute.SessionGenerator)
f.generator.zoneId.value = "arms"; f.generator.goalId.value = "strength"
f.generator.durationText.value = "47"; f.generator.preview.value = preview()
f.generator.warningAcknowledged.value = true; f.generator.editingIndex.value = 0
f.generator.setsText.value = "raw sets"; f.generator.repsText.value = "raw reps"
f.generator.restText.value = "raw rest"; f.generator.loadText.value = "42,5"
assertFalse(f.controller.openSection(AppSection.EQUIPMENT))
assertEquals(AppRoute.SessionGenerator, f.navigation.route)
assertTrue(f.controller.hasPendingNavigation)
f.controller.keepAndNavigate()
assertEquals(AppRoute.Equipment, f.navigation.route)
assertEquals("arms", f.generator.zoneId.value); assertEquals("strength", f.generator.goalId.value)
assertEquals("47", f.generator.durationText.value); assertNotNull(f.generator.preview.value)
assertTrue(f.generator.warningAcknowledged.value); assertEquals(0, f.generator.editingIndex.value)
assertEquals("raw sets", f.generator.setsText.value); assertEquals("raw reps", f.generator.repsText.value)
assertEquals("raw rest", f.generator.restText.value); assertEquals("42,5", f.generator.loadText.value)
}
@Test fun productionGuardBackKeepsExerciseRawStateAndDiscardClearsOnlyExercise() {
val f = fixture(AppRoute.ExerciseCreate(AppRoute.Exercises))
f.exercise.name.value = " Traction brute "; f.exercise.searchQuery.value = "catalogue retained"
f.equipment.customName = "equipment retained"; f.body.values[0].value = "body retained"
assertFalse(f.controller.back())
f.controller.keepAndNavigate()
assertEquals(AppRoute.Exercises, f.navigation.route)
assertEquals(" Traction brute ", f.exercise.name.value)
f.navigation.open(AppRoute.ExerciseCreate(AppRoute.Exercises))
assertFalse(f.controller.openSection(AppSection.SYNC))
f.controller.discardAndNavigate()
assertEquals(AppRoute.Sync, f.navigation.route); assertEquals("", f.exercise.name.value)
assertEquals("catalogue retained", f.exercise.searchQuery.value)
assertEquals("equipment retained", f.equipment.customName)
assertEquals("body retained", f.body.values[0].value)
}
@Test fun explicitDiscardTargetsEquipmentBodyAndGeneratorIndependently() {
val equipment = fixture(AppRoute.EquipmentCreate(AppRoute.Equipment))
equipment.equipment.customName = "Ma machine"
assertFalse(equipment.controller.open(AppRoute.Home)); equipment.controller.discardAndNavigate()
assertFalse(equipment.equipment.dirty)
val body = fixture(AppRoute.BodyMeasurements)
body.body.values[0].value = "72,5"; body.body.values[6].value = "raw arm"
assertFalse(body.controller.back()); body.controller.discardAndNavigate(); assertFalse(body.body.dirty)
val generator = fixture(AppRoute.SessionGenerator)
generator.generator.preview.value = preview(); generator.generator.setsText.value = "raw"
generator.generator.warningAcknowledged.value = true
assertFalse(generator.controller.openSection(AppSection.HOME)); generator.controller.discardAndNavigate()
assertFalse(generator.generator.hasUnacceptedWork); assertNull(generator.generator.preview.value)
assertEquals("", generator.generator.setsText.value); assertFalse(generator.generator.warningAcknowledged.value)
}
@Test fun dirtyEditorCannotBeSilentlyReplacedByDifferentEditor() {
val f = fixture(AppRoute.ExerciseEdit("ex_a", AppRoute.Exercises))
f.exercise.name.value = "raw retained"
assertFalse(f.controller.open(AppRoute.ExerciseEdit("ex_b", AppRoute.Exercises)))
f.controller.keepAndNavigate()
assertEquals(AppRoute.ExerciseEdit("ex_a", AppRoute.Exercises), f.navigation.route)
assertEquals("raw retained", f.exercise.name.value)
assertFalse(f.controller.open(AppRoute.ExerciseEdit("ex_b", AppRoute.Exercises)))
f.controller.discardAndNavigate()
assertEquals(AppRoute.ExerciseEdit("ex_b", AppRoute.Exercises), f.navigation.route)
assertEquals("", f.exercise.name.value)
}
@Test fun productionNavigationLeavesRepositoryDatabaseSnapshotUnchanged() {
val repository = TrainlogRepository(context, databaseName)
repository.listSessions(); repository.close()
val before = databaseDigest()
val f = fixture(AppRoute.SessionGenerator)
f.generator.durationText.value = "45"
assertFalse(f.controller.openSection(AppSection.SYNC)); f.controller.keepAndNavigate()
f.controller.open(AppRoute.Settings); f.controller.back()
assertArrayEquals(before, databaseDigest())
}
private fun databaseDigest() = MessageDigest.getInstance("SHA-256")
.digest(context.getDatabasePath(databaseName).readBytes())
private fun fixture(initial: AppRoute): Fixture {
val n = AppNavigationState(initial); val g = SessionGeneratorUiState()
val eq = EquipmentScreenState(); val ex = ExerciseScreenState(); val b = BodyScreenState()
return Fixture(n, g, eq, ex, b, AppNavigationController(n, g, eq, ex, b))
}
private fun preview(): SessionGenerationPreview {
val empty = ExposureWindowSummary(0, 0, 0, emptyList())
return SessionGenerationPreview(
SessionGenerationRequest("arms", "strength", 47, "2026-09-10T12:00:00Z"), emptyList(), 0, false,
BodyZoneRecentExposure(empty, empty, false, false, GenerationWarningLevel.NONE, null, null, null, emptyList(), 0),
)
}
private data class Fixture(
val navigation: AppNavigationState, val generator: SessionGeneratorUiState,
val equipment: EquipmentScreenState, val exercise: ExerciseScreenState,
val body: BodyScreenState, val controller: AppNavigationController,
)
}

View file

@ -4,7 +4,11 @@ import com.labfytools.trainlog.model.ExerciseProfile
import com.labfytools.trainlog.model.RecordingMode
import com.labfytools.trainlog.model.SessionDraftForm
import com.labfytools.trainlog.model.SessionExerciseDraft
import com.labfytools.trainlog.model.SessionExercisePlan
import com.labfytools.trainlog.model.SessionLoadMode
import com.labfytools.trainlog.model.SessionSetDraft
import com.labfytools.trainlog.data.EquipmentLoadSemantics
import com.labfytools.trainlog.data.ManualPercentMaxResult
import com.labfytools.trainlog.model.TrackingMode
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
@ -125,6 +129,44 @@ class SessionSetRowEditorTest {
assertEquals("sxe_test", form.editingEntryId)
}
@Test
fun manualTargetPlanNeverChangesPerformedWeights() {
val actuals = listOf(
SessionSetDraft(reps = 10, weightKg = 40.0),
SessionSetDraft(reps = 8, weightKg = 42.5),
)
val draft = SessionExerciseDraft(exercise = repsExercise, equipmentId = "leg_press", sets = actuals)
val result = buildManualTargetPlan(
draft, null, ManualTargetChoice.PERCENT_MAX, "",
ManualPercentMaxResult.Available(100.0, "2026-09-10T08:00:00Z", 73.0),
EquipmentLoadSemantics.EXTERNAL,
).getOrThrow()!!
assertEquals(SessionExercisePlan(2, reps = 10, weightKg = 73.0,
loadMode = SessionLoadMode.EXTERNAL), result)
assertEquals(actuals, draft.sets)
assertNull(buildManualTargetPlan(draft, result, ManualTargetChoice.NONE,
"", null, EquipmentLoadSemantics.EXTERNAL).getOrThrow())
}
@Test
fun directKgPreservesExistingDoseAndPercentRejectsAssistance() {
val draft = SessionExerciseDraft(exercise = repsExercise,
equipmentId = "leg_press", sets = listOf(SessionSetDraft(reps = 6)))
val existing = SessionExercisePlan(3, reps = 8, weightKg = 50.0,
loadMode = SessionLoadMode.EXTERNAL, restSeconds = 120)
val direct = buildManualTargetPlan(draft, existing, ManualTargetChoice.KG,
"62,5", null, EquipmentLoadSemantics.EXTERNAL).getOrThrow()!!
assertEquals(existing.copy(weightKg = 62.5), direct)
val assistance = buildManualTargetPlan(draft, existing,
ManualTargetChoice.PERCENT_MAX, "",
ManualPercentMaxResult.Available(100.0, "2026-09-10T08:00:00Z", 70.0),
EquipmentLoadSemantics.ASSISTANCE)
assertEquals(
"Le %MAX est indisponible pour une assistance ; choisissez une résistance externe.",
assistance.exceptionOrNull()?.message,
)
}
private val repsExercise =
ExerciseProfile(
exerciseId = "ex_00000000-0000-4000-8000-000000000001",

View file

@ -0,0 +1,141 @@
package com.labfytools.trainlog.ui
import android.content.Context
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.performSemanticsAction
import androidx.compose.ui.semantics.SemanticsActions
import androidx.test.core.app.ApplicationProvider
import com.labfytools.trainlog.data.ActiveDraftMutationResult
import com.labfytools.trainlog.data.BodyZoneRecentExposure
import com.labfytools.trainlog.data.ExposureWindowSummary
import com.labfytools.trainlog.data.GenerationWarningLevel
import com.labfytools.trainlog.data.SessionGenerationPreview
import com.labfytools.trainlog.data.SessionGenerationPreviewExercise
import com.labfytools.trainlog.data.SessionGenerationRequest
import com.labfytools.trainlog.data.TrainingRecencyWarning
import com.labfytools.trainlog.data.SyncCatalogInbox
import com.labfytools.trainlog.data.SyncExporter
import com.labfytools.trainlog.data.SyncRequestOutbox
import com.labfytools.trainlog.data.TrainlogRepository
import com.labfytools.trainlog.model.SessionExercisePlan
import com.labfytools.trainlog.ui.theme.TrainlogTheme
import java.security.MessageDigest
import java.util.UUID
import org.junit.After
import org.junit.Assert.assertArrayEquals
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertTrue
import org.junit.Before
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 TrainlogAppNavigationCallbackTest {
@Suppress("DEPRECATION")
@get:Rule val compose = createComposeRule()
private lateinit var context: Context
private lateinit var databaseName: String
private lateinit var repository: TrainlogRepository
private lateinit var appState: TrainlogAppState
@Before fun setUp() {
context = ApplicationProvider.getApplicationContext()
databaseName = "app-navigation-callback-${UUID.randomUUID()}.db"
repository = TrainlogRepository(context, databaseName)
assertEquals(ActiveDraftMutationResult.Saved, repository.startActiveSessionDraft())
appState = TrainlogAppState().also {
it.navigation.open(AppRoute.SessionGenerator)
it.generator.preview.value = preview()
it.generator.warningAcknowledged.value = true
it.generator.setsText.value = "raw sets retained"
}
}
@After fun tearDown() {
repository.close()
context.deleteDatabase(databaseName)
}
@Test fun existingDraftAcceptUsesProductionGuardAndRetainsPreviewUntilResolution() {
compose.setContent {
TrainlogTheme {
TrainlogApp(
repository,
SyncExporter(context, repository),
SyncCatalogInbox(context, repository),
SyncRequestOutbox(context),
appState,
)
}
}
compose.waitForIdle()
val before = databaseDigest()
// CONTRACT: exercise the callback installed by TrainlogApp itself; a
// controller-only call would not detect a root-composition bypass.
compose.onNode(
hasText("Accepter et saisir les valeurs réelles") and hasClickAction(),
).performSemanticsAction(SemanticsActions.OnClick)
compose.waitUntil(5_000) { appState.navigationController.hasPendingNavigation }
assertEquals(AppRoute.SessionGenerator, appState.navigation.route)
assertTrue(appState.navigationController.hasPendingNavigation)
assertNotNull(appState.generator.preview.value)
assertEquals("raw sets retained", appState.generator.setsText.value)
assertArrayEquals(before, databaseDigest())
compose.onNodeWithText("Conserver et quitter")
.performSemanticsAction(SemanticsActions.OnClick)
compose.waitForIdle()
assertEquals(AppRoute.Sessions, appState.navigation.route)
assertNotNull(appState.generator.preview.value)
assertEquals("raw sets retained", appState.generator.setsText.value)
assertFalse(appState.navigationController.hasPendingNavigation)
assertArrayEquals(before, databaseDigest())
}
private fun databaseDigest(): ByteArray = MessageDigest.getInstance("SHA-256")
.digest(context.getDatabasePath(databaseName).readBytes())
private fun preview(): SessionGenerationPreview {
val empty = ExposureWindowSummary(0, 0, 0, emptyList())
return SessionGenerationPreview(
SessionGenerationRequest("full_body", "general", 30, "2026-09-10T12:00:00Z"),
listOf(
SessionGenerationPreviewExercise(
exerciseId = "fixture_exercise",
exerciseName = "Exercice de contrôle",
equipmentId = "fixture_equipment",
equipmentName = "Équipement de contrôle",
primaryZoneId = "full_body",
primaryZoneName = "Corps entier",
patternIds = emptyList(),
patternNames = emptyList(),
plan = SessionExercisePlan(2, reps = 10, restSeconds = 90),
estimatedSeconds = 300,
recency = TrainingRecencyWarning(false, false),
rationaleCodes = emptyList(),
loadSourceSessionId = null,
loadSourceOccurrenceId = null,
loadSourceStartedAt = null,
),
),
300,
false,
BodyZoneRecentExposure(
empty, empty, false, false, GenerationWarningLevel.NONE,
null, null, null, emptyList(), 0,
),
)
}
}

View file

@ -24,14 +24,56 @@ never silently rewritten as V3.
```text
Accueil
├── Reprendre la séance en cours (si un brouillon existe)
├── Enregistrer une séance
├── Enregistrer un exercice
├── Enregistrer des mensurations
├── Historique des séances
└── Synchroniser avec le PC
Séances
Exercices
Équipements
Statistiques
Synchronisation
Paramètres
```
The fixed Android application shell uses a Material 3 modal drawer, a
`Scaffold`, system sans-serif typography, and local small VectorDrawable
icons. Each of the seven destinations is a complete drawer item with a
minimum 48 dp touch target. The active drawer section is derived from the
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 roots are:
```text
Accueil durable-draft resume, generation/manual capture, concise local links
Séances current session, programme a session, manual entry, completed sessions
Exercices catalogue, detail, create, and contextual edit
Équipements catalogue, detail, and custom-equipment creation
Statistiques measurements and local latest explicit MAX
Synchronisation existing request, result, and diagnostic workflow
Paramètres exchange-folder authorization and explicit PC-catalog refresh
```
`AppRoute` is typed and carries stable IDs and an explicit caller where a
detail or creation workflow needs one. `AppNavigationController` is the sole
owner of route transactions in the Compose root. Its bounded history returns
inline exercise/equipment creation to the caller exactly once. Back first
resolves visible input/overlays and the drawer; it then follows the caller or
route history, falls back to the section root, then Home.
Leaving an unaccepted generator proposal or a dirty exercise, equipment, or
measurement form installs an explicit keep/discard guard. Keeping retains the
transient editor state and its current route; discard clears only the
transient state that raised the guard. Route changes do not save, synchronize,
finalize, or otherwise write repository data. A successful generator
acceptance clears its transient proposal before routing; an
`existing_active_draft` result remains guarded and retains the proposal until
the user resolves it.
Route content uses a `SaveableStateHolder`, keyed by stable route identity,
with a 16-entry bound for modest list/scroll state. The root `ViewModel`
retains navigation and transient forms across Activity recreation. It does not
serialize a session or generated proposal into a Bundle: after process death,
only the repository-owned durable active draft is restored through Home.
## 3. Local persistence
Android local database version:
@ -57,13 +99,16 @@ This database is Android-local. It is not copied to the PC.
Schema v4 introduced `active_session_draft`, `draft_session_exercises`,
`draft_performed_sets` and `draft_continuous_activity`. The implemented
additive v4 -> v10 chain preserves catalog, completed sessions/actuals, body
additive v4 -> v11 chain preserves catalog, completed sessions/actuals, body
observations and the draft while adding the shared equipment catalogue,
occurrence-level equipment links and stable completed/draft `entry_id` values.
Exactly one active draft is supported; it is separate from completed history.
Schema v9 adds explicit completed/draft MAX results. Schema v10 additively
stores direct primary/secondary body-zone relations and their private sync
baseline; the taxonomy itself remains the shared manifest asset.
baseline; the taxonomy itself remains the shared manifest asset. Schema v11
additively stores optional occurrence/draft planning metadata after the explicit
v10 -> v11 migration; existing rows retain `load_mode=none`, zero rest and NULL
targets.
## Session generator V1
@ -80,6 +125,31 @@ non-mutating `existing_active_draft` conflict. Empty results cannot be accepted;
nonempty partial results may be accepted and edited normally. Final completion
continues to require actual captured work.
Load editing offers compact choices for automatic V1 qualification, a
user-selected `%MAX`, or no numeric target; direct manual kg remains available.
`%MAX` accepts an integer 1..100 and uses exactly
`MAX × percentage / 100` only for the chronologically latest explicit MAX of
the same exercise ID and same external-resistance equipment ID. Assistance and
incompatible/missing contexts produce an empty target labelled
`compatible_max_unavailable`; this is unavailable rather than a fallback or a
recommendation. Only the resulting plan `target_weight_kg` is persisted on
acceptance; the percentage and MAX provenance are transient. Zone, objective,
duration and load choices use compact localized chips and do not expose
internal IDs.
The ordinary set-based manual exercise form exposes the same separation from
actuals with **Valeur en kg / % de mon MAX / Aucune**. It composes the confirmed
choice into the existing `SessionExerciseDraft.plan`; performed-set weights are
never used as target storage. Editing an existing generated/manual occurrence
preserves its target dose and rest while allowing the numeric target to change.
The read-only `%MAX` lookup shows the compatible MAX date/value and calculated
target before confirmation, recomputes for exercise/equipment/percentage
changes, and performs no draft mutation by itself.
The preview shows requested and estimated duration, warns on a meaningful
shortfall without padding, and states that warm-up and cool-down are absent in
V1. `SESSION_GENERATOR_V2` remains future-only.
## 4. Exercise catalog
Exercise creation records:
@ -131,10 +201,9 @@ referenced, Android displays the lock and returns an explicit incompatible
profile result rather than silently reinterpreting work or creating another
exercise. Renaming remains available independently.
The shared Compose `TrainlogScreen` header is used by Accueil, Séance,
Exercice, Mensurations, Historique, Détail séance and Sync. Its compact
`◆ TRAINLOG ◆` accent plaque and muted subtitle intentionally mirror the
Notcurses TUI identity in a flat mobile layout.
The shared Compose shell supplies navigation context and the page header. Its
content host provides one scrollable destination area; screens do not redraw a
global banner or own a competing navigation control.
## 5. Session recording
@ -364,8 +433,14 @@ result.
Before applying the PC catalog or its V2 artifacts, Android applies
`trainlog-pc-equipment-definitions-v1.json`. Thus custom definitions are known
before a received V2 association references them. Android schema v10 provides
before a received V2 association references them. Android schema v12 retains
the non-destructive v7 -> v8 migration required for `load_semantics = none`.
It also applies `trainlog-exercise-aliases-v1.json` before catalog and session
reconciliation. The additive v11 → v12 alias table lets legacy exercise IDs
resolve to one live canonical row; a live-source rekey preserves completed
occurrences, the durable draft, selected form exercise, equipment relations,
actuals, MAX and targets. Android republishes the same deterministic companion
with no exercise-merge UI.
After catalog/session/equipment reconciliation, Android applies the same
body-zone companion so custom exercises receive their classifications.
@ -406,17 +481,24 @@ adb install -r app/build/outputs/apk/debug/app-debug.apk
`local.properties` is local machine configuration and must not be committed.
The current JVM host regression suite has 44 passing tests when the retained
real v9 fixture is enabled. The prior device
instrumentation suite had 5 tests (2 repository, 3 production-screen UI tests
using an isolated database and no shared export). That device matrix exercised
production `MainActivity`,
including verified process exit with `am kill`, force-stop, configuration
relaunch, raw-form recovery, removal, discard and unchanged user data. Final-save
UI checks used isolated data so fictitious workouts did not enter user history.
The schema-v8 definition change is recorded as targeted JVM validation, not a
blanket device-validation claim. See [tests](tests.md) for commands and the
precise validation boundary.
The APP_SHELL_V1 Android validation ran the JVM command above successfully:
84 tests ran, with 83 passing and one skipped historical-fixture test. The skip
is `RealAndroidV9BodyZonesMigrationTest.realVersionNineCopyMigratesWithoutChangingExistingTables`,
whose external `TRAINLOG_ANDROID_V9_FIXTURE` was unavailable. The build
produced `app/build/outputs/apk/debug/app-debug.apk` (12,649,975 bytes;
SHA-256 `ddb1221d25db5be60eb2261d4b1dcf0fb7446e2a780c67aae862f37c15b7396d`).
The host regressions include the production Compose callback for an existing
active draft: it verifies that the generator's route, preview and raw input
remain intact until the explicit keep/discard decision, and that keeping does
not write the initialized SQLite database. The expected nine shell icons and
ten referenced catalogue assets were checked byte-for-byte in the APK.
No emulator is installed, and this checkpoint did not install or run on the
connected daily phone. Human visual/accessibility review remains pending for
320/360/393/412 dp widths, 100/130/200% font scale, IME behavior, drawer and
form reachability, long translated/source text, scroll restoration feel, and
TalkBack. See [tests](tests.md) for the broader validation boundary.
## 14. Non-goals
@ -507,7 +589,7 @@ frozen desktop/Python normalization contract uses NFC, Unicode whitespace
collapse and case folding without accent removal. Existing Marche/Leg press
data is unaffected, but changing this safely requires an explicit Android
schema migration that recomputes every normalized key and handles newly exposed
collisions. It is not silently changed inside schema v10.
collisions. It is not silently changed inside schema v12.
The bundled exercise/equipment relationship metadata is seeded and preserved,
including during exercise-identity reconciliation, but the current equipment
@ -543,10 +625,11 @@ follow-up pages. Occurrence and set limits are 132 and 164 respectively.
Cursors order current data chronologically by original timestamp, session ID
and occurrence ID, and do not preserve a snapshot across calls.
This read-only feature makes no Android schema change (the runtime schema
remains v10), does not seed rows, and does not export/synchronize new data. It
does not implement recommendations, planned weights, set counts or fatigue
scores. Its occurrence and latest-MAX readers use the same explicit temporal
This read-only feature makes no further Android schema change (the runtime
schema remains v11), does not seed rows, and does not export/synchronize new
training-knowledge data. It does not implement recommendations or fatigue
scores; planning metadata belongs to the separate schema-v11/session-generator
contract. Its occurrence and latest-MAX readers use the same explicit temporal
grammar, exact fractional comparison and bytewise ID tie breakers as C.
The Android writer's omitted-seconds form is admitted, and emitted cursors
retain the original source text. Production pagination/MAX parity tests pass.

View file

@ -82,6 +82,34 @@ It consumes core services for:
- manual synchronization;
- synchronization log/detail display.
APP_SHELL_V1 places these existing capabilities below seven platform-neutral
sections: Accueil, Séances, Exercices, Équipements, Statistiques,
Synchronisation and Paramètres. The controller owns route history, focus,
overlays and transient leave guards; rendering owns no persistence, SQLite,
transport, or synchronization decisions. Navigation and redraw alone never
write a database or run synchronization. Session drafts, generator previews,
and transient forms use explicit keep/discard guards; discard of transient TUI
state performs no database write. Sync operations retain their existing
direction confirmation and diagnostic ownership.
The desktop controller has one event loop and consumes Trainlog input semantics
before screen code. Overlay input precedes route aliases, local editors precede
global aliases, and overlay close restores saved focus/stable selection. Its
layout is compact from 72x20, has a sidebar from 100x26, and expands it from
120x32. Its bounded UTF-8 adapter, stable-ID list state, shared action registry
and run-scoped terminal planes keep Notcurses infrastructure separate from
product semantics.
Android's `AppNavigationController` is the corresponding route owner. Its
Material 3 drawer exposes the same seven roots and derives drawer selection
from the route. It uses local vector resources, a sans-serif hierarchy and
minimum 48 dp actions; it has no runtime icon/parser dependency and accepts a
text fallback where an optional icon font is unavailable. Route transitions
preserve durable drafts and require keep/discard resolution for non-durable
forms or generator previews. The Android shell maps the same semantic color
roles to Material 3 tokens; neither platform treats color as the only state
carrier.
### `trainlog-syncd`
`trainlog-syncd` is a small user-session agent.
@ -140,7 +168,7 @@ silently rendered as unclassified.
### Desktop
Desktop SQLite schema v11 is canonical long-term history. Its v9 -> v10
Desktop SQLite schema v12 is canonical long-term history. Its v9 -> v10
migration losslessly rebuilds only `performed_sets` so actual `weight_kg` may
be finite `>= 0`; the column already existed and targets/max results retain
their strictly-positive contracts. `session_exercises`
@ -152,6 +180,12 @@ and a private synchronization baseline, then seeds only stable-ID mappings
whose decision evidence is recorded in the manifest. It never rewrites an
exercise, occurrence or history row.
The additive v11 -> v12 migration adds only persistent exercise aliases. An
explicit source-to-canonical merge validates the complete recording profile and
primary BODY ZONE, unions compatible secondary zones, repoints occurrence
foreign keys, and then retires the source catalogue row. Entry IDs, sessions,
sets, continuous activity, MAX, equipment and targets remain unchanged.
Main tables:
```text
@ -169,7 +203,7 @@ exercise_body_zone_sync
### Android
Android has an independent local SQLite schema, currently v11. Completed and
Android has an independent local SQLite schema, currently v12. Completed and
draft MAX values use one-to-one `max_results` and `draft_max_results` rows;
resuming a completed Test max records its stable source session in the one
durable draft.
@ -206,9 +240,10 @@ equal recording/tracking modes, compatible represented invariants, and
sides; the richer mask is retained and every occurrence/draft reference moves
transactionally. Otherwise synchronization reports a conflict.
Compose presentation has one `TrainlogScreen` header component for every page.
It uses the TUI's compact accent `◆ TRAINLOG ◆` plaque and muted context line;
screen navigation and data ownership remain independent from the header.
Compose presentation has one fixed `AndroidAppShell` Material 3 `Scaffold`
with a `TopAppBar` above its page-content host. Root routes show `TRAINLOG`;
non-root routes show their route title. Screen navigation and data ownership
remain independent from this shell.
## 5. Compatibility boundaries

View file

@ -65,8 +65,9 @@ BODY_ZONES_ANDROID_DEVICE_VALIDATION=PASS
TRAINING_KNOWLEDGE_V1=PASS
SESSION_GENERATOR_V1=PASS
APP_SHELL_V1=IMPLEMENTED_AWAITING_VISUAL_REVIEW_2
DESKTOP_TESTS=45/45 PASS (latest validated checkpoint)
DESKTOP_TESTS=47/47 PASS (normal and ASan/UBSan Meson suites)
ANDROID_BUILD=PASS
HARDWARE_SYNC_VALIDATION=HISTORICAL_PASS
```
@ -75,10 +76,12 @@ HARDWARE_SYNC_VALIDATION=HISTORICAL_PASS
Implemented:
- C17/Notcurses true-color TUI (72x20 minimum, UTF-8 prompts, resize fallback);
- C17/Notcurses true-color TUI with the APP_SHELL_V1 persistent seven-section
shell (72x20 minimum, UTF-8 bounded editors, resize fallback, F6 navigation,
F7 actions, and restored focus after overlays/routes);
- UTF-8 cell-aware scrolling training-knowledge screen, tested at the 72x20
minimum terminal;
- SQLite schema v11, with stable ordered `session_exercises.entry_id`,
- SQLite schema v12, with stable ordered `session_exercises.entry_id`,
occurrence-level equipment identity, and desktop-local custom-equipment
definitions, plus occurrence-owned `max_results`; its v9 -> v10 migration
rebuilds only `performed_sets` to permit explicit zero actual loads while
@ -105,18 +108,21 @@ Implemented:
- manual Android -> PC, PC -> Android, and bidirectional synchronization;
- structured synchronization history and detail.
Primary navigation:
Primary navigation is now shared with Android:
```text
0 Accueil
1 Séance
2 Historique
3 Exercices
4 Équipements
5 Corps
6 Sync
Accueil | Séances | Exercices | Équipements | Statistiques | Synchronisation | Paramètres
```
`Séances` contains the current draft, the existing generator, manual entry,
and **Séances effectuées**. `Statistiques` exposes existing mensurations,
body views and explicit MAX/performance views only. Navigation neither writes
data nor launches synchronization. The second automated repair review covered
the TUI root search/action paths, selection/focus restoration and section hubs,
plus Android compact controls, hidden internal IDs and latest-MAX display
semantics. Human visual/accessibility review is still required before this
status can advance.
## Android
Implemented:
@ -154,9 +160,10 @@ tracking modes match, other known invariants are compatible, and one
the existing desktop identity is canonical on desktop. The richer bit-mask
union is retained without rewriting historical occurrence snapshots.
All Android screens use the shared compact `◆ TRAINLOG ◆` header: the
Notcurses accent, muted context line, and flat touch layout reproduce the TUI
plaque without literal terminal box drawing.
All Android screens are hosted by the fixed `AndroidAppShell` Material 3
`Scaffold` and `TopAppBar`: root routes show `TRAINLOG`, non-root routes show
their route title, and page content is rendered beneath that shell. The drawer,
top bar and content host keep navigation separate from page ownership.
## Training knowledge V1
@ -164,7 +171,7 @@ The implemented read-only training-knowledge layer loads six versioned JSON
catalogs as the sole authored scientific source, generates the immutable C
catalog representation, and loads the same assets on Android. It has no
database migration, no auto-seeding, and no synchronization artifact. The
desktop database remains schema v11 and Android remains schema v11.
desktop database remains schema v12 and Android remains schema v12.
Desktop `training_knowledge.h` and Android `TrainingKnowledgeCatalog` expose
source-linked science lookups and resolved-candidate filters. Desktop
@ -193,7 +200,7 @@ errors and one known missing-real-v9-fixture skip; Java 17 `assembleDebug` also
passed. Generated C is byte-identical with SHA-256
`e8c099f67eb111d61621b5d76592c049823af5508d43e73ec646f22e4c377fca`; all six
Android assets are byte-identical. Preservation before and after repair confirms
schema v11/v10, unchanged catalog/science/temporal bytes, and unchanged real
historical desktop/Android schemas v11/v10, unchanged catalog/science/temporal bytes, and unchanged real
database logical SHA-256 `26139cafeffbde3ec08f6ef23c5069e75afb9cd69ffffb40be5c099006fedc4d`,
counts, integrity, and foreign keys. `TRAINING_KNOWLEDGE_V1=PASS`. The
[temporal contract](reviews/training_knowledge_v1_temporal_contract.md)
@ -327,10 +334,10 @@ upgrade/install or hardware MTP exercise is claimed.
Desktop:
```text
39/39 Meson tests PASS for the current desktop schema v11 baseline
Historical checkpoint: 39/39 Meson tests PASS for desktop schema v11
JSON valid/invalid checks PASS
import-contract validator 6/6 PASS
ASan/UBSan 39/39 Meson tests PASS with leak detection
Historical checkpoint: ASan/UBSan 39/39 Meson tests PASS with leak detection
standalone public-header C17 syntax PASS
real Notcurses binary zone workflows PASS in Kitty
git diff --check PASS
@ -430,7 +437,7 @@ ASSISTANCE_DIRECTION_AWARE=PASS
ANDROID_MAX_TEST_SESSION=PASS
EXPLICIT_MAX_RESULTS_V1=PASS
MAX_TEST_RESUME_STABLE_ID=PASS
DESKTOP_TESTS=39/39 PASS
DESKTOP_TESTS=47/47 PASS
```
A measured maximum belongs to an exercise occurrence in an explicit `max_test`
@ -451,6 +458,15 @@ record compares max tests using the same load mode.
External-load working percentages are pure calculations from the current
measured load; they are not persisted and no estimated 1RM is introduced.
`PERCENT_MAX_INPUT_V1` additionally provides a user-directed 1..100 integer
calculator in desktop planning, Android ordinary set-based manual planning, and
both generator previews. It requires the exact exercise and
external-resistance equipment identity; assistance is unavailable. It uses
`MAX × percentage / 100`, makes no recommendation, and persists only the
resulting `target_weight_kg`. Automatic generation retains the frozen V1
observed-load policy. Latest MAX means the chronologically latest explicit
result, not an aggregate record.
## Body analytics v1
```text
@ -460,7 +476,7 @@ BODY_COMPOSITION_ESTIMATE=PASS
BODY_PROPORTION_RATIOS=PASS
BODY_SYMMETRY_ANALYTICS=PASS
NO_ESTIMATE_PERSISTENCE=PASS
DESKTOP_TESTS=39/39 PASS
DESKTOP_TESTS=47/47 PASS
```
Android remains capture-only for this feature.

View file

@ -3,8 +3,8 @@
## 1. Status
```text
TRAINLOG_DATABASE_SCHEMA_VERSION=11
DATABASE_SCHEMA_V11=PASS
TRAINLOG_DATABASE_SCHEMA_VERSION=12
DATABASE_SCHEMA_V12=PASS
TRAINLOG_FORMAT_V1=FROZEN
```
@ -23,7 +23,7 @@ PRAGMA user_version;
Current value:
```text
11
12
```
The independent actual-set loads documented in the current desktop, Android
@ -68,6 +68,12 @@ There is no exercise-ID, occurrence-ID, session, performed-set, MAX, equipment
or body-observation rewrite. The migration is one transaction and uncertain
historical exercises remain valid with no relation.
Version 12 is additive. It creates `exercise_aliases`, whose unique source ID
points to one live canonical `exercises.exercise_id`. Valid writes keep this
mapping collapsed: a merge repoints occurrence ownership transactionally,
preserves all stable occurrence and child-row identities, unions compatible
direct zones, and rejects profile or primary-zone conflicts before mutation.
A schema fixture must represent the real historical structure. Rewriting only
`user_version` is not an acceptable migration test.
@ -103,6 +109,17 @@ Rules include:
- unknown supplemental field bits are rejected;
- normalized names remain unique.
### `exercise_aliases`
```text
source_exercise_id PRIMARY KEY retired creator identity
canonical_exercise_id foreign key -> exercises(exercise_id), restrict delete
```
Sources and targets differ. Targets are always live catalog identities, and a
target is never another alias source; this makes resolution bounded and rejects
chains/cycles in imported companion artifacts.
### `exercise_body_zones`
Direct exercise-to-zone relations:
@ -431,7 +448,7 @@ Do not synchronize SQLite database files.
## 10. Session-generation planning metadata
Desktop schema remains v11. Android schema v11 adds, through its additive
Desktop schema v11 and Android schema v11 add, through their additive
v10 -> v11 migration, `load_mode`, `rest_seconds`, `target_sets`,
`target_reps`, `target_duration_seconds`, and `target_weight_kg` to both normal
completed and durable-draft occurrences. Existing rows receive mode `none`,
@ -465,7 +482,19 @@ max_results
max_sync
```
The current normal desktop suite contains 39 tests.
The current normal desktop suite contains 47 tests.
## APP_SHELL_V1 read-only equipment paging
`trainlog_database_list_custom_equipment_page()` is a desktop read-only page
reader for the equipment catalogue UI; it is not a migration or a schema
change. It accepts an offset, caller-owned output buffer, and a capacity of
1 through 128. It reads at most `capacity + 1` rows in deterministic
`display_name COLLATE NOCASE, equipment_id` order. `output_more` is true only
when that one extra ordered row exists. Every output is valid only on `OK`;
invalid arguments, invalid/overflow offsets, and corrupt non-text or
embedded-NUL values fail explicitly. The offset is a refreshable presentation
position while data is unchanged, never a durable cursor or idempotency token.
## 11. Explicit and legacy measured maxima
@ -540,10 +569,15 @@ Desktop schema v11 and Android schema v10 then add only body-zone relation and
sync-baseline tables. Both seed exact manifest mappings by stable exercise ID;
neither migration changes the occurrence/equipment/MAX graph described above.
Desktop and Android schema v12 add only the durable flattened
`exercise_aliases` mapping. It resolves retired creator IDs to a live canonical
exercise during catalog and companion reconciliation without changing any
session, set, continuous, MAX, equipment, body-zone or planning wire shape.
## 13. Body analytics persistence rule
Body analytics still require no dedicated schema change; schemas v9 through
v11 do not alter their measurement storage.
v12 do not alter their measurement storage.
Canonical persistence continues to contain only measurements actually entered
by the user.
@ -566,7 +600,7 @@ history.
## 14. Training knowledge read boundary
Training Knowledge V1 adds no table, migration, seed data or synchronization
artifact. The desktop database remains schema v11. Read-only context assembly
artifact. The desktop database remains schema v12. Read-only context assembly
joins an exact existing exercise with its persisted BODY ZONE relations,
occurrence history, raw sets, actual equipment and latest explicit MAX, then
optionally attaches immutable catalog knowledge. Missing catalog knowledge is

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 188 KiB

View file

@ -0,0 +1,887 @@
# APP_SHELL_V1 — proposition soumise au design gate
Date : 10 septembre 2026. Baseline inspectée : `7cb4996`, après
`TRAINING_KNOWLEDGE_V1=PASS` et `SESSION_GENERATOR_V1=PASS`.
Le dépôt était propre à l'ouverture. **Design seulement ; aucune autorisation
d'implémentation n'est déduite de ce document.** Ce dossier est une proposition,
pas une description de fonctionnalités déjà livrées ni un contrat FROZEN.
Les [maquettes Android](android_mockups.html) sont consultables hors ligne,
avec un [aperçu PNG](android_overview.png). Les [maquettes TUI en couleur](tui_mockups.html)
et leur [version texte](tui_mockups.txt) donnent les quatre grilles exactes et les
overlays compacts. Les données affichées sont fictives, uniquement dans ces
documents. Aucun catalogue ni aucune base utilisateur n'est modifié.
## 1. Décision proposée
Un même classement de l'information, deux interactions natives :
- Android : drawer modal de premier niveau, pages de section et navigation
vers les détails. Touches d'action de 48 dp minimum, contenu compact et défilant.
- TUI : shell persistant Notcurses ; navigation latérale quand la surface est
suffisante, panneau de navigation temporaire dans les petits terminaux.
- Un titre et un emplacement stables pour chaque fonction. La sélection de la
rubrique est dérivée de la destination ; elle n'est pas un second état.
- Mocha/Lavender, typographie système sur Android, hiérarchie par poids et
alignement dans le terminal. Les surfaces regroupent ; les cadres ne décorent pas.
L'ancien **Historique** devient **Séances → Séances effectuées**.
**Programmer une séance** héberge le générateur existant : préparer une séance
unique, modifier sa proposition puis l'accepter dans l'éditeur ordinaire.
Ce libellé n'introduit ni calendrier, ni modèles réutilisables, ni programme
multi-séances, ni seconde séance planifiée persistante.
## 2. Ce que l'inspection a établi
| Sujet | Implémentation constatée | Conséquence pour le shell |
|---|---|---|
| Android navigation | `TrainlogApp.kt:21` : enum de huit écrans ; `remember`, cible de retour particulière pour la création d'exercice, ID de séance sélectionné ; `BackHandler` revient souvent à Accueil | Introduire un propriétaire unique de routes et des retours hiérarchiques/caller-aware |
| Android cadre | `TrainlogComponents.kt:39` : colonne entièrement défilante, bannière comprise ; composants Foundation faits maison | Extraire un vrai shell fixe ; une seule zone défilante par écran |
| Android thème | `TrainlogTheme.kt` : accent Teal `#94e2d5`, muted Blue, toute la police monospace | Passage explicite à Lavender et à une hiérarchie sans-serif ; conserver les rôles métier |
| Android dépendances | BOM Compose `2026.08.00`, activity-compose `1.13.0`, Foundation/UI ; pas Material 3 ni navigation-compose ni bibliothèque d'icônes | L'ajout de Material 3 est un choix de la future implémentation, pas une dépendance supposée présente |
| TUI structure | `tui.c` contient environ 13 000 lignes, des boucles d'écran imbriquées et des états locaux | Extraction progressive des contrôleurs ; une seule boucle d'événements finale |
| TUI surfaces | `terminal.c:14` possède le contexte et le plan standard ; `TrainlogPanel` est une vue de coordonnées et son commit est vide | Les panneaux actuels ne peuvent pas servir de shell persistant sans changer l'adaptateur |
| TUI état terminal | `tui.c:63` utilise un pointeur file-static pendant `trainlog_tui_run()` | Remplacer cet accès implicite par un contexte d'application passé explicitement |
| Notcurses | `pkg-config notcurses-core` et `/usr/include/notcurses/version.h` : **3.0.17**, liaison `-lnotcurses-core` | Sélection d'API fondée sur ce header, sans mise à niveau nécessaire |
| Équipements | Android : choix/création dans la saisie ; TUI : catalogue autonome. Création/liste/résolution disponibles, modification de définition absente | Ajouter le point d'entrée Android en réutilisant les opérations existantes ; ne pas inventer un éditeur de définition |
| Persistance | Desktop et Android v11 dans le code et `docs/current_state.md` ; brouillon Android durable, brouillon TUI en mémoire | Le shell ne crée aucune migration ; ne pas promettre une reprise TUI après arrêt du processus |
Le texte desktop v9 de `AGENTS.md` §8 est un état ancien par rapport au code
et aux documents actuels v11. La proposition ne résout pas cet écart par une
modification de schéma. La formule « un plan standard » de `docs/tui.md`
décrit le backend existant ; les plans enfants proposés conservent un seul
contexte et un seul plan standard. Les contrats produit et de format restent
intacts. La nouvelle séquence demandée ici sera synchronisée dans la roadmap
lors de l'implémentation approuvée, sans annoncer le shell comme déjà livré.
## 3. Architecture de l'information finale
Légende : **V1** = exposé par la future implémentation APP_SHELL_V1 ;
**existant PC** = fonction déjà disponible seulement sur desktop ;
**futur** = emplacement réservé, absent des menus V1 tant que non implémenté.
```text
Accueil V1
Séances V1 : page de section
Séance en cours V1 : état/reprise
Programmer une séance V1 : générateur existant
Nouvelle séance manuelle V1 : action
Séances effectuées V1 : ancien Historique
Détail d'une séance
Exercices / occurrences / séries / activité / MAX / plan
Modifier selon capacité actuelle
Reprendre ce Test max Android, stable ID
Exercices V1 : ouvre Catalogue
Catalogue V1
Fiche exercice V1
Zones du corps / connaissances et sources V1
Équipements compatibles / utilisés selon données disponibles
Performances / MAX liens vers vues existantes
Modifier V1, action contextuelle
Créer V1, action de catalogue
Équipements V1 : ouvre Catalogue
Catalogue V1, autonome aussi Android
Fiche équipement V1
Fourni / Personnel / Référence inconnue V1, selon résolution réelle
Modifier la définition futur, API métier absente
Créer V1, capacités actuelles
Statistiques V1 : page de section utile
Vue d'ensemble futur STATS_V1
Fréquence futur STATS_V1
Progression futur STATS_V1
Par exercice existant PC ; enrichi STATS_V1
Zones du corps futur STATS_V1
Mensurations V1
Relevés / Ajouter / Détail
Modifier / Graphiques / Analyse corporelle existant PC
Vue sur 12 mois existant PC, déplacée d'Accueil
Capacités / MAX V1, vues actuelles seulement
Derniers MAX locaux Android
Mesures / historique / graphe / charges de travail existant PC
Synchronisation V1
État / Lancer / Résultat / Diagnostic capacités actuelles
Journal / Détail d'une synchronisation existant PC
Dossier d'échange / Récupération Android
Paramètres V1, contenu par plateforme
Profil d'estimation corporelle existant PC
Dossier d'échange Android, grant SAF existant
```
**Créer et Modifier sont des actions, pas trois catalogues parallèles.**
On ouvre Exercices directement sur son catalogue ; « Créer » est visible en
tête. « Modifier » concerne l'exercice sélectionné ou sa fiche. Même logique
pour Équipements lorsque l'opération existe. « Modifier l'équipement de cette
occurrence » reste une action de séance : ce n'est pas la modification d'une
définition du catalogue.
Les sous-rubriques futures de Statistiques ne sont ni des boutons grisés, ni
des pages « bientôt disponible ». Le hub V1 montre uniquement Mensurations,
Capacités / MAX et, sur PC, Par exercice. STATS_V1 ajoutera des destinations
en utilisant les mêmes routes de section et les mêmes composants. Les zones
actuelles restent pleinement visibles dans Exercices et Programmer ; aucune
analyse par zone n'est anticipée. Android conserve sa mission de capture et de
consultation locale ; réserver une route n'autorise pas une copie de toutes
les analyses desktop.
Sur Android, « Voir les derniers MAX » depuis une fiche exercice ouvre la
liste agrégée existante sous Statistiques → Capacités / MAX. Ce lien ne promet
pas une page MAX filtrée par exercice, qui reste du ressort de STATS_V1.
Sur TUI, le contexte d'exercice continue à ouvrir ses vues de performance/MAX
existantes ; depuis Statistiques, un sélecteur d'exercice fournit ce contexte.
Paramètres a une véritable première destination sur chaque plateforme.
Pas d'interrupteur de thème, notifications, unités ou police sans fonction
réelle. Le profil d'estimation et le grant SAF gardent leurs propriétaires
actuels ; les anciens accès contextuels pointent vers le même éditeur.
## 4. Inventaire exhaustif des capacités visibles et destination
Cette matrice distingue les plateformes : préserver n'exige pas d'inventer
sur Android une opération réservée aujourd'hui au PC.
| Capacité actuelle | Android | TUI | Destination / conservation |
|---|---|---|---|
| Reprise séance en cours | Brouillon durable, résumé, avertissements de récupération | Éditeur courant en mémoire | Accueil → Reprendre ; Séances → Séance en cours |
| Nouvelle séance, normal/Test max | Oui | Oui | Séances → Nouvelle séance manuelle → type |
| Ajouter/éditer/retirer une occurrence ; même exercice plusieurs fois | Oui | Oui | Séance en cours → occurrences distinctes par `entry_id` |
| Créer un exercice pendant la saisie | Oui, avec retour au formulaire | Oui, via sélecteur | Action « Créer un exercice » dans la sélection ; retour au même appelant |
| Séries réelles hétérogènes, charge par série, valeur absente/0, virgule décimale Android | Oui | Oui | Éditeur d'occurrence, valeurs réelles séparées du plan |
| SETS + DURATION ; CONTINUOUS + DURATION ; vitesse/distance si configurées | Oui | Oui | Même éditeur piloté par métadonnées, aucun set fictif |
| MAX explicite sans séries | Oui | Oui | Type Test max ; résultat d'occurrence |
| Abandon explicite / sauvegarde / erreurs de validation | Brouillon durable, abandon confirmé, finalisation atomique | Édition en mémoire, écriture finale transactionnelle | Actions contextuelles, diagnostic persistant et retour sans écrasement |
| Génération par zone, objectif, durée personnalisée/préréglée | Oui | Oui | Séances → Programmer une séance |
| Exposition récente, avertissement, couverture partielle/vide, raisons et provenance de charge | Oui | Oui | Paramètres/proposition/détail du générateur ; toutes les explications consultables |
| Proposition modifiable, supprimer/réordonner/régénérer/accepter/annuler | Oui ; édition dans l'aperçu | Aperçu puis édition ordinaire après acceptation | Même point d'entrée ; respecter les étapes réellement disponibles, pas de réécriture de politique |
| Liste des séances terminées, date/type, détail des actuals et du plan | Oui, historique local | Oui, historique canonique | Séances → Séances effectuées → Détail |
| Correction complète d'une séance persistée, retrait d'exercice, rollback | Non, pas d'éditeur général actuel | Oui | Détail → Modifier sur PC |
| Reprendre le même Test max terminé en gardant ID/date | Oui | Édition persistée existante | Détail du Test max ; respect du conflit de brouillon existant |
| Changer/retirer l'équipement d'une occurrence terminée | Oui | Oui via édition | Détail → occurrence → Équipement |
| Catalogue d'exercices, recherche préfixe normalisé | Oui | Oui | Exercices → Catalogue |
| Créer/éditer nom et zones d'exercice ; profil protégé si référencé | Oui | Nom/zones et création selon capacités actuelles | Créer / Fiche → Modifier ; IDs inchangés ; aucun renommage en masse |
| Zone primaire, secondaires, groupes descendants, Non renseignés | Oui | Oui | Catalogue : filtre et résumé ; Fiche : détail ; Éditeur : sélecteur existant |
| Connaissances exercice, confiance, références scientifiques, cas non résolu | Oui | Oui, vue défilante | Fiche → Connaissances ; affichage existant sans nouvelle inférence |
| Équipements compatibles et usages historiques d'un exercice | Connaissances / contexte existants | Relations du manifeste et usages historiques séparés | Fiche → Équipements ; ne pas présenter un usage passé comme une preuve de compatibilité |
| Meilleures performances ordinaires | Pas de page analytique dédiée | Oui | Statistiques → Par exercice ; lien `p` depuis fiche/catalogue |
| Derniers MAX explicites | Dans Historique | Vue MAX | Statistiques → Capacités / MAX ; lien dans Séances effectuées |
| MAX : historique, graphe, résultat récent/meilleur, arrondi 0,5/1/2,5/5 kg | Consultation locale simple | Oui | Capacités / MAX → exercice ; assistance inverse et absence de pourcentage conservées |
| Catalogue équipements fournis, recherche par nom/étiquette/alias, détail | Pendant saisie | Autonome | Équipements → Catalogue ; sélecteur partagé dans l'éditeur |
| Création équipement personnel | Nom simple, sémantique actuelle | Nom/étiquette/type/mode de charge | Catalogue → Créer et action dans sélecteur, sans élargir les contrats des formulaires |
| Équipement historique introuvable | Résolution selon données reçues | Référence inconnue explicite | Fiche/occurrence : « Référence inconnue » et identifiant lisible |
| Saisie de toutes les mensurations actuelles | Oui | Oui | Statistiques → Mensurations → Ajouter ; raccourci Accueil |
| Relevés corporels récents | Oui, résumés | Oui, sélection/détail/édition | Mensurations → Relevés |
| Correction relevé : identité/date/lien séance préservés | Non | Oui | Mensurations → Détail → Modifier |
| Tendances, normalisation multi-mesures, distinction gauche/droite | Non | Oui | Mensurations → Graphiques |
| Graphique mensuel 12 mois aujourd'hui sur Accueil | Non | Oui | Mensurations → Vue sur 12 mois, mêmes mois vides et même sélection du dernier relevé |
| Composition, tendances, proportions, symétrie et libellés d'estimation | Non | Oui | Mensurations → Analyse corporelle, deux pages actuelles |
| Taille et branche de formule du profil d'estimation | Non | Oui | Paramètres → Profil d'estimation ; raccourci `p` conservé dans l'analyse |
| USB/MTP appareil, stockage, rafraîchir | Côté PC | Oui | Synchronisation → État |
| Android→PC, PC→Android, bidirectionnel et confirmation exacte | Requête Android bidirectionnelle | Trois actions directes | Synchronisation ; aucun nouveau protocole ni menu de mode intermédiaire |
| Snapshot automatique, import catalogue automatique, effets après sauvegarde | Oui | Import/export par moteur | Mêmes services et mêmes déclencheurs ; pas de relance à chaque navigation |
| Autoriser/changer dossier SAF, relire catalogue PC | Oui | Sans objet | Paramètres → Dossier d'échange ; liens directs dans Synchronisation |
| Requête en attente, reçu, diagnostic de résultat | Oui | Moteur/daemon | Synchronisation ; badge global seulement s'il reflète un état connu |
| Journal structuré, direction, détail, erreurs/conflits | Reçu courant | Oui | Synchronisation → Journal → Détail |
| UTF-8, clavier, petits terminaux, resize, aide et raccourcis | Touch/TalkBack/clavier matériel | Oui | Contrats transversaux du shell |
L'audit d'implémentation devra parcourir cette matrice ligne par ligne. Les
anciens écrans n'ont pas tous la même profondeur de fonctions : la proposition
ne promet pas une parité métier qui n'existe pas.
## 5. Android : drawer, pages de section, retour
Retenir **ModalNavigationDrawer + ModalDrawerSheet + NavigationDrawerItem**,
avec `Scaffold` et barre supérieure Material 3. La dépendance `material3`
sera ajoutée en s'alignant sur le BOM déjà utilisé. Les composants officiels
prennent en charge le drawer modal ; le choix des hubs est une décision UX
de Trainlog. [Documentation Android du drawer](https://developer.android.com/develop/ui/compose/components/drawer).
Le drawer contient sept lignes : Accueil, Séances, Exercices, Équipements,
Statistiques, Synchronisation, Paramètres. Pas de sous-arbre déroulant : les
longs libellés et les rubriques futures allongeraient inutilement le parcours
sur téléphone. Chaque ligne a un pictogramme, un libellé et une zone tactile
complète ; la rubrique active porte un fond Surface 0, une marque latérale
Lavender et l'état sémantique sélectionné. Sous Séances, un petit texte peut
indiquer « Séance en cours » si elle existe ; ce texte n'est pas un second lien.
La sélection d'une rubrique ouvre sa racine et ferme le drawer. Réappuyer sur
la rubrique courante revient à sa racine, avec le même garde de formulaire si
nécessaire. Les listes mémorisent recherche, filtre et position. Pas de piles
de navigation indépendantes par item de drawer.
Aux racines : `☰ TRAINLOG` et un titre de page normal dans le contenu.
Dans un détail : flèche Retour et titre court dans la barre, contexte de
rubrique en sous-texte ; le menu secondaire contient une action « Navigation »
pour changer de rubrique sans empiler des retours. Le drawer demeure le même.
Pas de hamburger et flèche Retour concurrents dans le même emplacement.
Ordre de Retour : IME si ouvert → dialogue/feuille → drawer → route appelante
→ hub/racine → Accueil → comportement Android normal. L'éditeur de brouillon
Android conserve ses données via le repository ; Retour ne supprime rien.
Les formulaires non durables et la proposition non acceptée demandent de
confirmer une sortie qui perdrait les modifications. L'ouverture du drawer
seule ne quitte pas le formulaire.
La création inline transporte une intention de retour avec l'ID de l'occurrence
ou du formulaire appelant. Après création, l'exercice reste sélectionnable et
le même éditeur reprend ; aucun second brouillon et aucun retour forcé Accueil.
## 6. Android : cadre et composants
```text
TrainlogTheme (MaterialTheme + rôles complémentaires)
AndroidAppShell
ModalNavigationDrawer
Scaffold
TopAppBar fixe, contexte/navigation/actions courtes
ScreenHost une seule destination, insets appliqués une fois
SectionHeading titre 22 sp, contexte court
SearchAndFilters si liste
LazyColumn / Form contenu défilant
ContextActionBar seulement si formulaire/aperçu, au-dessus IME
SnackbarHost feedback ponctuel non critique
Dialog / ModalBottomSheet garde de sortie, confirmation, choix borné
```
Un écran fournit titre, filiation et liste d'actions sémantiques. Il ne
redessine ni bannière globale ni bouton Retour géant dans son contenu.
Un seul bouton principal rempli par contexte : Reprendre, Générer, Accepter
la proposition ou Terminer la séance. Les actions secondaires sont des
boutons texte ; les actions par ligne ont un menu accessible « Actions pour… ».
Les suppressions ne dominent pas Accueil : elles vivent dans l'éditeur/menu,
avec confirmation. Les avertissements importants restent dans le contenu et
près de l'action concernée ; un snackbar ne suffit pas pour une erreur de
persistance ou un avertissement du générateur.
Le formulaire de séance distingue clairement : **Exercice**, **Équipement**,
**Objectif prévu**, **Séries réalisées**. Une proposition acceptée affiche zéro
série réelle tant que l'utilisateur n'en a pas enregistré. Les nombres et les
unités sont alignés ; la cible n'est pas un placeholder qui pourrait être
enregistré comme une valeur réelle.
Les lignes du catalogue utilisent titre + contexte court, pas une grande
carte par exercice. Les connaissances détaillées sont une route lisible ou
une feuille défilante ; la confiance et les sources restent présentes.
## 7. Android : typographie, icônes, tailles
| Rôle | Proposition Android | Usage |
|---|---|---|
| Titre application | Sans-serif système 18 sp / 24, semibold | Barre TRAINLOG |
| Titre écran | 22 sp / 28, semibold | Séances, Exercices ; pas de titre géant |
| Titre section | 16 sp / 22, semibold | Dernière séance, Séries réalisées |
| Texte courant | 16 sp / 24 | Libellés, explications et champs |
| Texte secondaire | 14 sp / 20 | Équipement, date, contexte ; jamais erreur essentielle en 12 sp |
| Valeur importante | 24 sp / 30, medium | Un MAX isolé, durée estimée |
| Valeurs alignées | 16 sp / 24, chiffres tabulaires si pris en charge | kg, répétitions, durée, colonnes |
Utiliser la famille Android par défaut ; tenter `fontFeatureSettings="tnum"`
dans les valeurs, puis réserver `FontFamily.Monospace` aux cellules numériques
si la police système ne fournit pas l'alignement attendu. Pas de téléchargement
de police ni nouvelle dépendance typographique. Le texte suit `fontScale` ;
une maquette HTML système ne prétend pas reproduire exactement le moteur Compose.
Cibles tactiles **48 × 48 dp minimum**, ce qui dépasse la demande d'environ
44 dp et suit le minimum Android. Ne pas compacter les cibles pour faire entrer
une ligne ; empiler les actions. Vérifier TalkBack, ordre de lecture, intitulés
de suppression par série/occurrence, annonces d'erreur et absence de doubles
descriptions des icônes décoratives. [Accessibilité Compose](https://developer.android.com/develop/ui/compose/accessibility/api-defaults).
Il n'existe actuellement que les vecteurs du lanceur. Ajouter une petite
sélection de **Material Symbols en VectorDrawable XML**, rendus par `Icon` /
`painterResource`, même épaisseur et taille optique 24 dp. Pas de police
d'icônes ni de gros paquet `material-icons-extended`. Cette forme de ressource
est recommandée par la documentation Android actuelle.
[Icônes Compose](https://developer.android.com/develop/ui/compose/graphics/images/material).
## 8. Android : adaptation aux téléphones
| Largeur utile | Comportement proposé |
|---|---|
| 320 dp | Marge 16 dp ; toutes les actions principales sur une colonne ; valeurs d'une série sur deux lignes si nécessaire ; drawer `min(360 dp, largeur - 56 dp)` = 264 dp |
| 360 dp | Marge 16 dp ; lignes de catalogue sur deux lignes ; éditeur rep/charge compact avec actions de ligne séparées ; drawer 304 dp |
| 393412 dp | Même architecture, davantage de texte visible ; pas de nouvelle colonne de navigation ; drawer 337356 dp |
| Paysage / fenêtre réduite | Hauteur défilante, barre d'action au-dessus de l'IME ; pas de cartes à hauteur fixe |
| ≥600 dp | V1 garde le drawer modal ; formulaire centré avec largeur max utile, listes utilisant la surface. Sidebar/rail persistant possible plus tard, pas requis V1 |
Les largeurs sont celles de la fenêtre disponible, pas des modèles de téléphone.
À 200 % de police, le drawer peut réduire sa marge de fond visible à 24 dp
pour laisser davantage de place aux libellés. Titres/actions peuvent passer
sur plusieurs lignes avec césure lisible des mots longs ; on ne
réduit pas le texte. La barre d'action grandit avec son contenu et sa hauteur
est déduite du viewport. Un seul propriétaire applique les insets du système
et de l'IME, pour éviter leur double ajout.
## 9. Accueil et hub Séances
Accueil répond dans cet ordre :
1. Séance en cours : type, nombre d'occurrences, action **Reprendre**.
2. Sans séance : action principale **Programmer une séance**, puis Nouvelle
séance manuelle. Avec séance active, la reprise est prioritaire.
3. Dernière séance terminée : date, type, nombre d'exercices, accès au détail.
4. Dernier MAX explicite s'il existe et si la lecture actuelle le permet ;
libellé « Dernier MAX enregistré », avec exercice/date/équipement, sans flèche
de progression ni qualification de record inventée.
5. Accès rapide Mensurations et état de synchronisation connu.
Le compteur hebdomadaire et une synthèse de progression sont différés à STATS_V1 :
ils impliqueraient de figer de nouveaux calculs/calendriers sans nécessité
pour le shell. Le graphe corporel 12 mois actuel du PC reste accessible sous
Mensurations avec un lien depuis Accueil. Il ne disparaît pas du produit.
Le hub Séances présente quatre lignes/action groupées. Sans séance active,
« Aucune séance en cours » est du texte d'état, suivi de Programmer et Nouvelle
séance manuelle réellement actives. Avec une séance, « Nouvelle séance manuelle »
ouvre la reprise existante avec une explication ; aucune action n'écrase le
brouillon. Programmer peut ouvrir ses paramètres/aperçu ; l'acceptation
rencontre le conflit existant et propose Reprendre ou revenir à l'aperçu.
Un abandon du brouillon se fait explicitement dans son éditeur.
Pas de vignette « séance planifiée » fabriquée. La proposition non acceptée est
temporaire ; après acceptation, il s'agit de la séance en cours ordinaire.
## 10. TUI : architecture AppShell
```text
trainlog_tui_run(database empruntée)
TrainlogAppContext durée d'un run, pas global
NavigationState route + pile de retour bornée
SessionController brouillon actif en mémoire
ScreenController actif données / sélection / formulaire
FocusManager une cible logique
ActionModel actions disponibles du contexte
OverlayStack transactions UI temporaires
TrainlogTerminal seul propriétaire Notcurses
stdplane racine empruntée au contexte
header plan persistant
sidebar plan persistant si layout large
contentHost plan persistant
viewport(s) du contrôleur plans bornés aux rectangles alloués
footer plan persistant, deux lignes
overlayRoot(s) plans temporaires + widgets dédiés
```
Les plans persistants survivent aux changements d'écran. Un passage compact
peut désallouer la sidebar seule ; son état logique de navigation n'est pas
perdu. Le contentHost survit ; les vues filles sont démontées/remontées ou
redimensionnées. Un overlay ne détruit pas le contrôleur ou le contenu dessous.
Séparer les futurs fichiers par responsabilité : `app_shell.c`,
`navigation.c`, `focus.c`, `actions.c`, `layout.c`, `overlays.c`,
`components/list_view.c`, `components/search_field.c`, `components/form.c`,
`screens/{home,sessions,exercises,equipment,body,max,sync}.c`.
Ces noms sont une proposition interne, pas une nouvelle API publique.
`terminal.c` demeure la frontière Notcurses ; core/repository/sync ne voient
aucun plan, widget ni constante NCKEY.
Le contrôleur d'écran expose conceptuellement `enter`, `handle_action`,
`layout`, `render`, `leave`, `destroy` ; il retourne une intention sémantique
(`OpenRoute`, `OpenOverlay`, `Save`, `Back`), jamais un appel récursif à la
boucle d'un autre écran. Une seule boucle lit l'entrée et déclenche le rendu
composé. `render` ne fait pas de SQL et ne mute pas le domaine. Les lectures
sont effectuées par le contrôleur/data adapter avant rendu ; un résultat
immuable ou une erreur explicite est transmis à la vue.
## 11. API Notcurses sélectionnées et limites réelles
Source de signatures : `/usr/include/notcurses/notcurses.h` **3.0.17**.
Les pages web du projet sont complémentaires ; certaines synopsis HTML sont
mal formées, donc ne servent pas de déclaration C à recopier.
| Besoin | API disponible retenue | Discipline |
|---|---|---|
| Contexte | `notcurses_core_init`, `notcurses_stdplane`, `notcurses_render`, `notcurses_stop` | Un seul contexte, seul thread UI fait du rendu |
| Plans | `ncplane_create`, `ncplane_options` (`name`, `userptr`, `resizecb`), `ncplane_move_yx`, `ncplane_resize_simple` | Propriétaires explicites, rectangles contrôlés |
| Taille | `ncplane_dim_yx`, événement `NCKEY_RESIZE`, `notcurses_refresh` | Mesurer la géométrie courante, recalculer tout le layout avant rendu ; ne pas redimensionner stdplane manuellement |
| Resize callback | `ncplane_set_resizecb` | Marque le layout invalide seulement ; aucun SQL, mutation ou rendu récursif dans callback |
| Z-order | `ncplane_move_above/below`, `ncplane_move_family_top` | Empiler une famille d'overlay entière, footer au-dessus de la zone de contenu |
| Libération | `ncplane_destroy`, `ncplane_family_destroy` | Libérer les widgets avant leur famille restante ; aucune double destruction de leur plan |
| Couleurs/styles | `ncchannels_set_fg_rgb8`, `ncchannels_set_bg_rgb8`, `ncplane_set_channels`, `ncplane_set_styles`, `ncplane_set_base` | Rôles sémantiques centralisés ; bases opaques pour footer/overlay |
| Texte Unicode | Fonctions de sortie UTF-8 `ncplane_putstr_yx`, largeur en cellules via utilitaires existants/utf8proc | Couper/envelopper aux graphèmes, pas par octets ; unité de layout = cellule |
| Champ de recherche | `ncreader_create`, `ncreader_offer_input`, `ncreader_contents`, `ncreader_clear`, `ncreader_destroy` | Plan dédié, une seule ligne, contenu borné avant insertion ; contrat détaillé ci-dessous |
| Choix court | `ncselector_create`, `ncselector_offer_input`, `ncselector_selected`, `ncselector_destroy` | Type de séance/objectif/tri/actions courts ; `maxdisplay` calculé ; pas de catalogue complet dans le widget |
| Défilement | Viewport logique de liste + rendu des lignes visibles ; `ncplane_scrollup`/`ncplane_set_scrolling` seulement pour une zone contrôlée si utile | Le scrolling physique n'est pas la pagination des données |
| Souris | `notcurses_mice_enable(..., NCMICE_BUTTON_EVENT)`, `ncplane_translate_abs`, `notcurses_mice_disable` | Click/roue utiles, pas de mouvement continu nécessaire ; mêmes actions que clavier |
| Progression | `ncprogbar_create`, `ncprogbar_set_progress`, `ncprogbar_destroy` disponibles | Utiliser seulement si un vrai total/progrès est fourni ; sinon étape textuelle réelle, aucun pourcentage simulé |
`ncselector` est adapté aux petits choix, avec son titre et footer facultatifs
désactivés pour économiser des lignes. Il peut redimensionner son plan. Le
wrapper vérifie les longueurs/hauteurs autorisées ; si le choix ne tient pas,
le composant liste plat prend le relais. Le widget prend son plan en charge,
y compris en cas d'échec de création.
[Contrat officiel ncselector 3.0.17](https://notcurses.com/notcurses_selector.3.html).
`ncreader` n'est pas un éditeur de formulaire métier. Le wrapper intercepte
Entrée, Tab, Échap, F6 et F7 avant `offer_input`. Home/End, flèches, suppression
et caractères imprimables restent des entrées locales du reader focalisé.
Le `ncinput` brut reste privé à l'adaptateur. Le wrapper refuse les
contrôles/sauts de ligne et les insertions dépassant la capacité du champ,
assemble correctement les caractères composés, restitue la sélection/caret
et libère la copie allouée par `ncreader_contents`. La recherche catalogue
réutilise le plafond actuel de 200 octets UTF-8, avec message explicite au
dépassement. Défilement horizontal permis **seulement sous cette borne** ;
aucune croissance illimitée verticale. `NCREADER_OPTION_NOCMDKEYS` évite les
raccourcis implicites incompatibles. Le curseur n'est visible que tant que
le champ possède le focus. La saisie UTF-8 complexe fait l'objet d'un test
précoce ; si le reader ne tient pas ce contrat, le champ Trainlog existant
est conservé et rendu dans un plan dédié. Ce repli est défini, pas une promesse
d'API inexistante. [Limites documentées du reader](https://notcurses.com/notcurses_reader.3.html).
`ncmultiselector`, `ncmenu`, `ncreel`, `nctree` sont disponibles mais non retenus
pour le shell V1 : le modèle de zones primaire/secondaires existant ne doit pas
être remplacé par de simples cases indépendantes ; menu/reel/tree ajouteraient
une navigation ou des surfaces inutiles. Il n'existe pas de DataGrid métier
prêt à l'emploi dans les widgets inspectés. La table interactive Trainlog
compose de vrais viewports/headers avec sélection et événements, plutôt qu'un
texte statique ou un plan par ligne de toute la base.
**Le rattachement d'un plan enfant n'est pas un masque de clipping.** Chaque
plan de contenu doit rester dans son rectangle alloué ; les lignes sont
rendus dans le viewport visible, sans enfant déporté sous le footer. La hauteur
de liste ne devient jamais la hauteur totale du jeu de données. L'application
gère son OverlayStack : ce n'est pas une classe Notcurses inventée.
[Primitives de plans](https://notcurses.com/notcurses_plane.3.html).
## 12. Focus et dispatch TUI
Le focus est l'une des cibles : Navigation, Recherche, Liste/Table, Éditeur,
Actions du footer ou Overlay actif. Le header n'est pas un arrêt supplémentaire
sauf son contrôle Navigation en compact. Une seule cible reçoit une entrée.
Le focus visuel : `>` + libellé/ligne en Lavender et fond Surface 0. La
rubrique active porte aussi des crochets dans les maquettes texte et sa
sous-rubrique un point, distincts du `>` de focus. Une
sélection gardée dans une liste non focalisée conserve sa marque et un fond
neutre, mais pas le même accent que le composant actif. Un champ focalisé a
curseur et indication de champ ; une rubrique active du menu reste repérable
indépendamment du focus. Aucune couleur seule ne porte ces états.
Ordre du dispatch : resize/événements système → overlay supérieur → saisie
locale → actions du composant → raccourcis de route disponibles → action de
shell. PRESS/UNKNOWN et REPEAT volontaire restent une action logique ; RELEASE
est ignoré. Une entrée consommée n'est jamais réémise. Les lettres/chiffres
saisis dans un champ ne déclenchent pas de navigation.
Tab/Shift-Tab parcourt les composants visibles : navigation → recherche/filtre
→ contenu → actions du footer → navigation. En compact, le bouton Navigation
remplace la sidebar dans cette boucle. Dans un formulaire, Tab parcourt ses
champs puis ses actions et ressort ; aucun piège. Dans la table réelle existante,
Tab conserve sa fonction de cellule, et F6/F7 permettent de sortir directement.
Les cibles non disponibles sont absentes du parcours.
Après fermeture d'overlay, restaurer la cible logique et l'ID sélectionné,
pas un pointeur de plan détruit. Après resize, garder le même élément visible
et convertir le focus sidebar en contrôle Navigation si la sidebar disparaît.
## 13. Clavier et migration des raccourcis
Les anciens chiffres sont des **alias de destination**, pas les indices du
nouveau menu. Ils gardent donc leur sens même si Statistiques s'insère avant
Synchronisation. Les libellés du menu n'ont pas une numérotation trompeuse.
| Contexte / ancien raccourci | Nouveau comportement | Motif / compatibilité |
|---|---|---|
| `0` / Home : Accueil | Inchangé hors champ/overlay | Home dans un champ continue à déplacer le caret |
| `1` / F1 : nouvelle séance | Reprendre si active, sinon nouvelle manuelle | Évite de perdre le travail ; ne devient pas l'index de Séances |
| `2` / F2 : Historique | Séances → Séances effectuées | Même données, nouveau classement |
| `3` / F3 : Exercices | Catalogue Exercices | Inchangé |
| `4` / F4 : Équipements | Catalogue Équipements | Inchangé |
| `5` / F5 : Corps | Statistiques → Mensurations | Même données et actions |
| `6` : Sync | Synchronisation | Inchangé, F6 n'est pas un alias de 6 |
| `g` depuis Accueil | Programmer une séance | Même générateur ; ajouté au hub Séances seulement |
| Nouveau F6 | Navigation : focus sidebar ou ouvre panneau compact | Aucun ancien F6 ; aussi accessible comme contrôle textuel avec Tab |
| Nouveau F7 | Toutes les actions du contexte, aide comprise | Garantit accès aux raccourcis qui ne tiennent pas dans le footer |
| Nouveau `?` hors saisie | Aide contextuelle + alias globaux | Pas F1, déjà utilisé pour une séance |
| Tab/Shift-Tab : navbar/contenu | Cycle de composants, sens inverse conservé | Extension ; table de séries conserve son Tab-cellule |
| Flèches / PgUp / PgDown | Sélection, déplacement cellule, défilement/page du composant focalisé | Aucun changement implicite de rubrique en lisant une liste |
| Entrée liste exercice | Fiche exercice | Aligné sur `screen_exercise_detail`, malgré le raccourci descriptif ancien de docs/tui |
| `p` catalogue/fiche exercice | Performances de cet exercice | Vue existante réutilisée dans Statistiques |
| `m` catalogue/fiche exercice | MAX de cet exercice | Distinct de la performance ordinaire |
| `k` fiche/connaissances | Ouvrir/fermer connaissances | Inchangé |
| `e` fiche exercice | Modifier nom/zones | Stable ID ; pas EXERCISE_NAMING en masse |
| `a` catalogue exercice | Créer exercice | Inchangé |
| `n` catalogue équipement | Créer équipement | Conservé ; `n` n'est jamais raccourci Navigation global |
| `/` exercices/équipements | Recherche éditable, filtre en direct | Entrée rend le focus aux résultats ; aucun enregistrement |
| `/` séances effectuées | Même champ de recherche | Ajout ; filtre explicite de la liste, pas moteur de recherche global |
| `z` catalogue exercices | Cycle de zones actuel | Filtre interactif visuel ajouté sans changer descendants/Non renseignés |
| `x` catalogue exercices | Effacer recherche + filtre de zone | Inchangé ; ne pas généraliser à un contexte destructeur |
| `x` sélecteur équipement | Aucun équipement pour l'occurrence | Inchangé, ne devient pas effacement global de filtre |
| `e` / Entrée séance en cours | Éditer occurrence/séries | Inchangé |
| `r` séance en cours | Remplacer occurrence sélectionnée | Inchangé ; identité/ordre selon service existant |
| `a`, `d` séance | Ajouter / retirer occurrence | Retrait confirmé, catalogue intact |
| `f` séance | Enregistrer/terminer via garde actuelle | Inchangé ; action libellée dans F7 et le footer |
| `q` éditeur courant | Abandon explicite avec confirmation | Alias préservé ; ne quitte pas tout le processus |
| Échap éditeur global, auparavant abandon | Retour au hub en conservant l'édition en mémoire | Changement explicite requis pour une navigation sûre ; abandon via q/action |
| `a`, `d`/Delete, Entrée dans table de séries | Ajouter, supprimer, éditer cellule | Inchangé, jamais de valeurs cibles copiées |
| `f`/`b`, Échap dans table | Quitter la table vers l'occurrence ; Échap cellule annule sa saisie | Priorité locale conservée |
| Générateur : Entrée / `a` accepte l'aperçu ; `q`/Échap annule | Même actions et gardes ; détail via action dédiée | L'acceptation ne devient pas silencieusement « ouvrir détail » |
| Avertissement génération `c`/Entrée, `z`, `q` | Continuer, autre zone, annuler | Inchangé |
| Zones : `p`, Espace, `n`, Entrée, Échap | Primaire, secondaire, Non renseigné, valider, annuler | Modèle métier existant conservé |
| Détail séance : `e`, `i`, flèches/PgUp/PgDown | Éditer, fiche équipement, parcourir occurrences/lignes | Inchangé |
| Corps : `a`, `e`, `v`, `g` | Ajouter, modifier, analyse, superposition globale | Déplacé sous Mensurations ; `g` n'y génère pas une séance |
| Analyse corporelle : `p`, gauche/droite | Profil d'estimation, changer page | Même éditeur via Paramètres et même page analytique |
| Vue MAX : `r` | Cycle d'arrondi actuel | Pas de nouveau calcul |
| Sync : `a`, `p`, `b`, `r` | Android→PC, PC→Android, bidirectionnel, actualiser | Priorité locale ; `b` ne signifie pas Retour sur Sync |
| Sync : `s` anciennement retiré | Reste sans action | Ne pas réactiver un contrat obsolète |
| Sync confirmation : Entrée / Échap | Lancer une fois / annuler sans opération | Inchangé |
| Enfant : `b`/Échap Retour | Retour appelant, sauf conflits locaux ci-dessus | Aide affiche le sens exact du contexte |
| `q` racine application | Quitter, avec garde si état en mémoire | Aucun arrêt depuis un champ par lettre q |
| Confirmation historique `1`/`0` | Alias confirmer/annuler dans la confirmation de retrait concernée | Navigation globale suspendue ; focus initial sur Annuler |
Pas de Ctrl+S obligatoire : les terminaux peuvent l'interpréter comme contrôle
de flux. Les actions explicites et `f` restent la voie fiable. Les touches de
fonction ajoutées seront traduites dans `TrainlogKey`, sans fuite de NCKEY.
Garde de sortie du générateur : avant production d'un aperçu, Échap revient
au hub Séances, avec la garde de formulaire si une configuration modifiée
serait perdue. Une fois l'aperçu produit, `q`/Échap, Retour Android, navigation
par drawer et sortie de l'application passent par la même garde. Seule l'action
explicite « Abandonner la proposition » l'efface ; « Conserver et revenir »
la suspend dans le contrôleur en mémoire. Cela inclut ses modifications et
l'état d'acquittement de l'avertissement. Aucune restauration après mort du
processus n'est promise pour cet aperçu.
## 14. Listes, tables et recherche
Un modèle commun de composant transporte : ID stable de sélection, ordre,
requête, filtres, début de viewport, nombres connus, état chargement/erreur.
La liste ne prend pas l'index courant comme identité. Après édition, filtrage
ou sync, conserver l'ID si visible, sinon choisir le voisin déterministe et
annoncer le changement.
Les tables ont un en-tête fixe, des colonnes numériques alignées, une ligne
sélectionnée, le compteur de position (`3 / 24` si total connu), des marqueurs
`↑ autres` / `↓ autres`. Le mode compact replie les colonnes secondaires sous
la ligne sélectionnée ou dans le détail, sans masquer une valeur métier.
Les noms longs sont élidés en liste avec `…` ; la fiche révèle tout le texte.
Pas de défilement horizontal indispensable à 72 colonnes.
Recherche : `/` focalise un vrai champ de la liste, créé si nécessaire. Le
filtrage se met à jour à chaque modification validée du texte ; les lectures
sont coalescées et aucun résultat ancien ne remplace une requête plus récente.
Entrée revient à la liste en gardant le filtre. **Échap avec texte efface la
requête et reste dans le champ ; Échap à vide ferme le champ.** Un petit `×`
Android fait la même chose. Le filtre de zone ne disparaît pas sur Échap ;
`x` garde sa fonction complète dans le catalogue d'exercices.
Sémantique conservée : exercices = préfixe normalisé + filtre de zone ;
équipements = noms/étiquettes/alias via recherche existante. Pour les séances,
V1 filtre les libellés effectivement affichés (date et type) ; recherche par
exercice se fait depuis sa fiche/performances existantes. Ne pas promettre une
recherche plein texte historique sans définition. Les tris V1 restent ceux
des vues existantes ; ne pas ajouter une flèche de tri sans ordre réellement
implémenté. Un choix de tri futur aura toujours un tie-break par identité et
n'altérera pas les contrats chronologiques scientifiques.
Ressources : seuls les éléments visibles sont matérialisés graphiquement.
Les pages de lecture sont bornées, avec indicateur explicite quand le total
n'est pas connu (`lignes 112 · suite disponible`, pas un faux total). Certaines
API actuelles n'offrent que capacité + nombre et certaines vues capent leur
chargement (équipements 256, sync 64). La migration doit conserver un diagnostic
de résultat partiel ; elle ne doit pas annoncer une pagination exhaustive
automatique fournie par ces APIs. Ajouter au besoin des accesseurs de lecture
paginée **étroitement bornés**, avec le même ordre/filtres et sans schéma ni
écriture, fait partie du travail de support UI à spécifier avant codage.
L'absence de page suivante ne doit pas être confondue avec un plafond local.
Les données scientifiques ne reçoivent aucun plafond global nouveau.
## 15. Overlays et modalités
Confirmation courte : plan flottant centré, une surface et une bordure discrète,
texte explicite, boutons Annuler/Confirmer. Un seul chemin de validation évite
un double appel. Aide, détails longs, recherche contextuelle et récupération :
overlay défilant ou route de détail selon le besoin ; pas de succession de
boîtes minuscules pour éditer une séance entière.
L'OverlayStack possède : type, état temporaire, plan(s)/widget(s), action de
retour, cible de focus à restaurer. La profondeur est bornée par les parcours
autorisés (trois niveaux suffisent : formulaire → confirmation → aide), jamais
un empilement illimité. Un quatrième niveau est remplacé par une navigation
dans le panneau courant ou refusé avec diagnostic ; aucun état n'est perdu.
Tant qu'un overlay est ouvert, sa saisie est exclusive. Échap ferme le niveau
supérieur ou demande confirmation si sa fermeture perd une édition. Un clic
sur le fond ne valide ni ne déclenche l'écran du dessous. La navigation
globale est suspendue, notamment `1`/`0` dans une confirmation. Le footer
montre **les actions de cet overlay** ; il reste physiquement visible.
Le dimming est limité à la surface centrale, pas une couche opaque sur le
footer. À 72×20, l'overlay peut occuper tout le rectangle central 72×16 avec
une ligne de titre, contenu défilant et actions accessibles en footer ; ce
n'est pas un nouveau plein écran qui détruit le précédent.
Sync déjà confirmée : afficher la direction et l'étape réellement connue.
Ne pas ajouter un bouton Annuler si le moteur ne garantit pas l'annulation.
Conserver son exécution/exclusion actuelles ; pas de worker pool ni de nouveau
scheduler pour animer une barre. Les événements de navigation ne déclenchent
aucun second run ; à la fin, relire la taille et afficher le résultat.
## 16. Responsive TUI et rectangles exacts
Toutes les coordonnées ci-dessous sont zéro-based. Header : lignes `0..1`.
Footer : les deux dernières lignes. Le rectangle central est toujours
`(y=2, x=0, h=H-4, w=W)`. Les marges sont internes, pas des cadres externes.
| Terminal | Mode | Navigation | Contenu utile | Overlay |
|---|---|---|---|---|
| **120×35** | Étendu | Sidebar 22 colonnes, x0..21 ; séparateur x22 ; section active développée | x23..119, 97×31 ; tableau + détail inférieur ou deux colonnes si chaque bloc tient | Centré, largeur ≤80 et hauteur ≤29, dans rectangle central ; fond intact |
| **100×30** | Standard | Sidebar 22 colonnes, premier niveau seulement | x23..99, 77×26 ; sous-navigation dans hub/breadcrumb ; détail pleine largeur | Largeur ≤74, hauteur ≤24 ; champs sur une colonne si nécessaire |
| **80×24** | Compact | Aucun espace réservé à gauche ; `F6 Navigation` ouvre panneau de 32 colonnes | 80×20 ; listes sans panneau détail latéral | Confirmation ~64×10 ; formulaire jusqu'à 78×20 ; contenu défilant |
| **72×20** | Minimum | Aucun rail d'icônes ; Navigation devient overlay central complet | 72×16 ; titre/contexte compact et lignes de liste ; footer de 2 lignes intact | Jusqu'à 72×16, titre et viewport ; actions dans footer partagé |
Règle déterministe : sidebar si **W≥100 et H≥26** ; sous-section développée si
**W≥120 et H≥32**. Entre ces seuils, conserver le mode moins chargé. Un
terminal très large mais bas utilise donc le compact. Pas de mise à l'échelle
aveugle d'un rectangle unique. La sidebar étendue ne développe que la rubrique
active ; ses items futurs sont absents et son propre viewport peut défiler.
En compact, les sept rubriques sont toutes nommées dans Navigation. Séances
ouvre son hub avec les quatre actions ; pas besoin de deviner une icône.
Le header affiche « Séances / Effectuées » et « F6 Navigation » ; un chemin
très long est abrégé au parent + titre, le détail restant accessible.
Sous 72×20 : état petit terminal, dimensions actuelles/minimum lisibles,
action Quitter accessible et garde d'état en mémoire si nécessaire. Aucun
write, abandon ou changement de route sur resize. Les surfaces non adaptées
ne sont pas rendues ; le contrôleur reste vivant. Dès retour à une taille
valide, reconstituer la géométrie, le focus, le défilement et les overlays.
## 17. Footer contextuel : contrat anti-régression
Le footer appartient **uniquement au shell**, dans un plan opaque et réservé.
Ni écran, ni scrolling, ni modal ne peut peindre sur ses deux lignes. Il est
recomposé après chaque changement de focus/route/overlay et à chaque resize.
Le rendu de contenu reçoit un rectangle qui l'exclut. Éviter les chaînes de
raccourcis tronquées par `%.*s` comme méthode de layout.
Ligne 1 : déplacement/validation et actions les plus utiles au focus.
Ligne 2 : Navigation, Actions, Aide et Retour/Quitter quand disponibles. Les
alias redondants peuvent rester dans F7/Aide ; l'action n'est jamais supprimée
car la ligne est courte. Les intitulés viennent du même registre que le
dispatch ; toute action affichée possède un handler et toute action disponible
apparaît au moins dans F7. Un contexte vide expose Retour/Navigation/Aide.
Exemples tenant à 72 colonnes :
```text
↑↓ Choisir Entrée Détail / Rechercher e Modifier
F6 Navigation F7 Actions ? Aide Échap Retour
↑↓ Série ←→ Cellule Entrée Modifier a Ajouter d Supprimer
f Terminer F6 Navigation F7 Actions Échap Retour
a Android→PC p PC→Android b Bidirectionnel
r Actualiser F6 Navigation F7 Actions Échap Retour
Tab Choisir Entrée Confirmer Échap Annuler
Confirmation : Retirer cet exercice de la séance
```
Les avertissements ne remplacent pas le footer : une ligne/bannière défilante
au-dessus les porte, avec un marqueur permanent et un accès au texte complet.
Tester le footer sur liste vide, nom long, erreur, IME/reader, confirmation,
retour d'overlay, changement de focus, et resize aller-retour aux quatre tailles.
## 18. Tokens Catppuccin et stratégie d'icônes
**Recommandation : tokens plateforme synchronisés et documentés**, avec une
table canonique de rôles très courte. V1 n'ajoute pas un moteur de thème ni un
parseur JSON au démarrage, et n'emploie pas les catalogues scientifiques pour
les couleurs. Les adaptateurs C et Kotlin traduisent la même table. La revue
de changement de palette et une vérification légère de parité empêchent la
dérive observée aujourd'hui. Si plusieurs thèmes apparaissent plus tard,
la génération depuis un petit manifeste pourra devenir utile.
Palette de référence : [Catppuccin Mocha](https://catppuccin.com/palette/).
L'affectation sémantique suivante est la proposition Trainlog :
| Token | Couleur | Rôle |
|---|---|---|
| `background` | Base `#1e1e2e` | Zone principale |
| `chrome_surface` | Mantle `#181825` | Header, sidebar, footer |
| `backdrop` | Crust `#11111b` | Fond extérieur/atténuation d'overlay |
| `surface` | Surface 0 `#313244` | Groupe utile, ligne sélectionnée |
| `elevated_surface` | Surface 0 `#313244` | Dialogue, avec contour si séparation nécessaire |
| `separator` | Surface 1 `#45475a` | Séparation discrète, pas seule marque de focus |
| `text` | Text `#cdd6f4` | Contenu principal |
| `muted_text` | Subtext 1 `#bac2de` | Contexte, dates, équipement |
| `accent` / `focus` | Lavender `#b4befe` | Interaction, état actif |
| `on_accent` | Crust `#11111b` | Texte sur bouton rempli Lavender |
| `success` | Green `#a6e3a1` | Succès réel + libellé « Enregistré » |
| `notice` | Peach `#fab387` | Information demandant attention sans erreur |
| `warning` | Yellow `#f9e2af` | Avertissement explicite + texte |
| `error` | Red `#f38ba8` | Échec, erreur de saisie, suppression |
| `information` | Blue `#89b4fa` | Information utile + libellé, pas texte secondaire systématique |
Pas de nouvelle palette scientifique pour les graphes existants : conserver
leurs séries/semantiques, labels et différenciation, puis harmoniser leur
présentation dans UI_POLISH_V1 si nécessaire. Lavender ne signifie ni succès,
ni zone du corps, ni charge élevée.
Espacement Android : échelle 4/8/12/16/24 dp ; marge écran 16, séparations
internes 8/12, entre groupes 16/24. Rayon discret 812 dp sur une surface
groupée et sur les contrôles ; pas de capsule autour de chaque texte.
TUI : 1 cellule de séparation minimale, padding horizontal 12 cellules,
1 ligne entre groupes quand la hauteur le permet, sans conversion dp→cellule.
Les degrés d'emphase (normal/secondaire/actif/critique) sont communs, pas les
unités physiques ni les tailles de police.
| Concept | Android, vecteur suggéré | TUI, sens équivalent | Fallback terminal |
|---|---|---|---|
| Accueil | home | maison, si mode icônes choisi | Accueil |
| Séances | event_note | carnet/calendrier | Séances |
| Exercices | exercise | mouvement/exercice | Exercices |
| Équipements | fitness_center | haltère/matériel | Équipements |
| Statistiques | bar_chart | graphique | Statistiques |
| Synchronisation | sync | flèches de transfert | Synchronisation |
| Paramètres | settings | roue dentée | Paramètres |
| Connaissances | menu_book | livre | Connaissances |
TUI V1 fonctionne **par défaut avec les libellés et marqueurs Unicode simples**.
Les Nerd Font sont une amélioration opt-in, jamais détectées à tort à partir
de `TERM` ou de la seule largeur d'un glyphe : la présence réelle du dessin ne
se prouve pas ainsi. Si un mode Nerd est ajouté, il doit être réellement
actionnable dans les préférences de présentation ou une option locale explicite,
avec aperçu et retour immédiat au mode texte ; aucune modification de police
du terminal n'est tentée. En l'absence de cette option implémentée, aucun menu
ne la promet et le fallback textuel est la livraison V1.
Les glyphes ont une colonne fixe, mesurée en cellules ; s'ils ne tiennent pas,
le libellé est conservé et l'icône retirée. `>` marque la sélection sans
Nerd Font. Variante ASCII de flèches/traits si nécessaire ; accent/UTF-8 dans
les données reste conservé. Android n'emploie aucun glyphe Nerd.
## 19. Propriétaires d'état, durées de vie, reprise
| État | Propriétaire | Durée / restauration |
|---|---|---|
| Section active | Dérivée de la route canonique | Aucun booléen concurrent dans drawer/sidebar |
| Route, pile de retour, appelant inline | NavigationState plateforme | Pile bornée ; pas d'IDs d'objet dupliqués dans plusieurs états incohérents |
| Requête, filtre, sélection ID, scroll | État de destination | Conservé sur détail/retour et changement de section, cache borné |
| Android brouillon + raw partial fields | Repository/SQLite existants | Durable ; rechargé, jamais copié comme source de vérité dans le shell |
| TUI brouillon / correction de séance | SessionController d'un run | Conservé lors d'une navigation UI ; aucun engagement après sortie/crash |
| Proposition générée non acceptée | Contrôleur générateur | En mémoire ; aucune écriture à l'ouverture/render/resize/annulation |
| Correction non durable d'exercice/mensuration | Contrôleur formulaire | Sauvegarder explicitement ou confirmer perte ; erreur garde les valeurs |
| État de sync/reçu | Services actuels | Shell lit un résumé ; aucun deuxième moteur ni deuxième propriétaire d'opération |
| Focus et pile d'overlays | AppShell | Sauvegarde de cible logique, invalidation des handles de plan détruits |
| Plans/widgets | Adaptateur terminal / owner du composant | Libération explicite ; chaînes empruntées copiées avant destruction si nécessaires |
| Thème | Adaptateur plateforme, table de tokens | Données de présentation, hors DB métier et échange |
La route distingue la destination de son appelant : `sessions.completed.detail`
porte le session_id ; `stats.exercise.performance` porte l'exercise_id ; la
création inline reste une sous-route du workflow de séance et réutilise le
même composant de formulaire que la création standalone. Drawer/sidebar
dérivent leur sélection de cette route contextualisée ; le retour à l'appelant
n'exige pas un second état de rubrique. Un seul aperçu générateur et une seule
édition active sont retenus à la fois ; commencer un workflow qui remplacerait
un état non durable exige la garde de sortie.
Android : introduire un petit `AppRoute` typé (destination + IDs) et un
`AppState`/state holder à la racine. Une seule pile bornée suffit ; pas de
framework de navigation supplémentaire obligatoire pour V1. Des sauvegardes
Compose peuvent garder routes/requêtes/positions modestes, jamais sérialiser
une séance complète dans un Bundle. Les contrôleurs transitoires peuvent
survivre à une recréation d'Activity via state holder/ViewModel ciblé si utile ;
après mort du processus, l'Accueil propose explicitement la reprise du brouillon
durable, comme aujourd'hui. Une proposition non acceptée n'est pas restaurée
comme une séance. Ne pas ouvrir un écran de confirmation destructive à la
recréation sans revalider sa cible.
Les imports/exports racine et callbacks de sauvegarde actuels restent au niveau
de leur cycle de vie/service. Naviguer ne déclenche pas un `LaunchedEffect`
réinstallé par écran qui importerait plusieurs fois. Les résultats de génération
ou lectures asynchrones portent une identité de requête et ne mettent plus à
jour un écran détruit. Ne pas changer la réutilisation du repository ni partager
une connexion SQLite entre threads sans son contrat actuel.
TUI : une DB empruntée à `trainlog_tui_run(TrainlogDatabase *)`, fermée par son
propriétaire actuel. Aucun pointeur Notcurses process-global. Libérer : widgets
→ plans de contenu/overlays → plans du shell → contexte Notcurses. Sur échec
d'allocation, libérer uniquement ce qui a été créé et afficher une erreur
explicite ; ne pas perdre silencieusement un formulaire.
## 20. Plan d'implémentation après approbation explicite
1. **Figer le design approuvé et la matrice de parité.** Formaliser routes,
action IDs, propriété des états, géométrie et exceptions clavier. Confirmer
les requêtes paginées de support nécessaires sans élargir les règles métier.
2. **Socle de présentation.** Tokens C/Kotlin synchronisés ; Material 3 ajouté
au BOM, vecteurs locaux ; `AppRoute`/ActionModel. Contrôle C17 précoce pour
toute interface interne exposée via header, sans casser l'API/ABI publique.
Les commentaires WHY / CONTRACT / INVARIANT accompagnent chaque changement
de durée de vie, action, propriété, borne ou persistance dans le même patch.
3. **Shell TUI et banc de géométrie.** Contexte explicite, vrais plans, header,
footer, layout, focus, overlay, input adapter ; tests de footer à 72×20 avant
migration des longues listes. Aucun widget sur un plan possédé par le shell.
4. **Shell Android.** Extraire contenu de TrainlogScreen, drawer et scaffold,
retour hiérarchique, insets, actions standard ; aucun deuxième scroll root.
5. **Listes et pages en lecture.** Accueil, hubs, séances effectuées, catalogues,
connaissances, mensurations/MAX existants, sync journal. Déplacer le graphe
12 mois sans changer son calcul. Accès autonome équipements Android.
6. **Formulaires et flux sensibles.** SessionController TUI, éditeurs, MAX,
génération et acceptation, création inline, confirmations, SAF/sync. Migrer
les boucles TUI écran par écran vers le dispatch unique ; aucun ancien
`draw_shell` plein écran ne subsiste sur le parcours livré.
7. **Validation et revue.** Exécuter la matrice ci-dessous, revue ciblée puis
un audit final de tranche ; réparer les régressions et synchroniser les
documents canoniques après comportement stabilisé.
Les commits intermédiaires ne sont pas implicitement autorisés par ce plan.
Le présent design gate s'arrête avant l'étape 1 exécutable.
## 21. Validation prévue et risques à protéger
| Risque | Preuve exigée à l'implémentation |
|---|---|
| Footer effacé/coupé | Rectangles disjoints et capture de rendu aux quatre dimensions, après modals/focus/erreurs/resize ; F7 énumère toutes les actions |
| Contenu qui dépasse son parent | Test avec nom UTF-8 long, nombreux sets, overlay proche du bas ; aucune cellule peinte dans footer |
| Double destruction de widget/plan | Tests création/échec/destruction, ASan/UBSan sur allocation/resize et fermeture imbriquée |
| State perdu en naviguant | Brouillon Android brut `32,`, retour depuis création inline, rotation/background/force-stop ; TUI aller-retour de rubrique puis reprise |
| Faux engagement de durabilité TUI | Message explicite à la sortie avec séance en mémoire ; aucune base/brouillon sidecar ajouté |
| Générateur transforme cible en réel | Cas zéro actual, aperçu vide/partiel, avertissement non acquitté, accepter avec brouillon existant ; mêmes garde/rollback |
| Données/IDs perdus | Snapshot DB temporaire avant/après navigation seule ; aucune écriture, migration, renommage ; sauvegarde réelle contrôlée transactionnellement |
| Raccourcis cassés | Matrice ancien/nouveau ; collisions q/b/g/n/p/r/Home ; RELEASE ignoré ; widget ne redéclenche pas l'action |
| Dead navigation | Chaque destination visible et chaque action F7 mène à une capacité réelle, futures routes absentes |
| Analyse cachée/perdue | Parcours body, overlay, profil, MAX, pourcentages/arrondis et performance ordinaire ; aucun zéro artificiel ni changement de science |
| Sync répétée/altérée | Chaque direction conserve une confirmation et un appel moteur ; s inactif ; erreurs/reçus/SAF/manual recovery toujours accessibles |
| Unicode/search incomplets | Accents précomposés/décomposés, CJK, emojis/graphèmes, dépassement capacité, collage, noms longs ; aucune coupure d'octet |
| Liste incomplète présentée comme complète | Jeu au-delà des anciennes capacités ; indication/page suivante réelle, filtrage avant pagination |
| Petits téléphones/IME | 320/360/393/412 dp, portrait/paysage, police 100/130/200 %, TalkBack, cible 48 dp, boutons visibles au-dessus IME |
| Thème illisible | Contrastes mesurés, focus sans couleur, fallback sans Nerd Font, terminal sans true color testé |
Commandes futures : `meson compile -C build`, `meson test -C build
--print-errorlogs`, validateurs JSON/import, vérifications scientifiques
existantes si leurs consommateurs sont touchés, build Android Java 17
`assembleDebug`, tests UI/draft ciblés, C17 et ASan/UBSan au checkpoint C,
`git diff --check`, `git status --short`. Le hardware MTP et les captures
Android/TUI réelles demeurent des validations manuelles explicitement nommées.
Dans ce design pass, seule la cohérence des artefacts et l'inspection statique
sont validées ; aucun build de production ni test de dispositif n'est revendiqué.
## 22. Report explicite des tranches suivantes
| Tranche | Travail différé |
|---|---|
| EXERCISE_NAMING_V1 | Noms exercice distincts des noms de machine ; migration explicite de noms/affichage sans toucher exercise_id, entry_id, session_id, actuals, MAX, zones ou sync ; aucun exemple de maquette ne déclenche une migration |
| STATS_V1 | Vue d'ensemble, fréquence, progression, analyse par zones et enrichissement par exercice/capacités ; définition des calculs, périodes et périmètre Android ultérieure ; pas de graphiques fictifs pour remplir le menu |
| UI_POLISH_V1 | Ajustements visuels après usage réel : transitions, détails de densité, raffinements de graphes, icônes Nerd opt-in si non livrées, éventuelle navigation permanente grands écrans Android |
| Capacité distincte à définir | Modification des définitions d'équipement : règles d'identité, propriété fourni/personnel, synchronisation et mutation absentes aujourd'hui. Ni promise par APP_SHELL_V1 ni assimilée à EXERCISE_NAMING_V1 |
APP_SHELL_V1 livre l'organisation, le shell et la lisibilité fondamentales.
Le focus, le footer, l'accessibilité de base, la reprise et les fonctions
existantes ne sont pas différés sous prétexte de polish.
## 23. État du design gate
La proposition et ses maquettes sont destinées à la revue humaine. L'approbation
du design, lorsqu'elle sera donnée, précédera toute implémentation de production.
`APP_SHELL_V1_DESIGN=READY_FOR_HUMAN_REVIEW`
Aucune implémentation de production. Aucun renommage d'exercice. Aucun STATS_V1.
Aucun commit. Aucun push.

View file

@ -0,0 +1,122 @@
# APP_SHELL_V1 — éléments de revue du design
Date : 10 septembre 2026.
## Périmètre réellement exécuté
- Inspection du dépôt propre à `7cb4996` : `SESSION_GENERATOR_V1` après
`TRAINING_KNOWLEDGE_V1`.
- Scout TUI isolé, lecture seule : structure des écrans, clavier, header/footer,
géométrie, API terminal et Notcurses installé.
- Inspection Android par le parent : routes, state owners, formulaires,
brouillon/génération, historique/MAX, catalogues, mensurations, synchronisation,
thème, typographie, ressources vectorielles et dépendances.
- Advisor `gpt-5.6-terra`, high, contexte isolé, lecture seule : première revue
de l'architecture puis vérification bornée des documents/maquettes écrits.
- Création exclusive d'artefacts dans `docs/design/app_shell_v1/`.
Aucune modification de code de production, DB, catalogue, build, format ou
document canonique décrivant l'implémentation actuelle.
## Conclusion de la revue architecturale
L'advisor n'a relevé aucune contradiction architecturale bloquante dans le
design final examiné. Il a confirmé notamment : propriétaire unique de route,
état Android durable / TUI limité au run, alias clavier indépendants de l'ordre
visuel, F6/F7 suffisants, rectangles header/content/footer, absence de clipping
implicite des plans enfants, ownership des widgets, routes futures masquées,
pas d'édition fictive de définition d'équipement, pas de migration métier.
Deux précisions demandées lors de sa dernière lecture ont été intégrées :
1. Le lien de fiche exercice Android devient **Voir les derniers MAX** et ouvre
la liste existante dans Statistiques. Il ne prétend pas fournir une page
d'analyse filtrée par exercice inexistante aujourd'hui.
2. Toute sortie d'une proposition générée existante passe par une garde
explicite ; seule **Abandonner la proposition** l'efface. Une sortie sûre
peut la conserver dans son contrôleur en mémoire. Cette règle couvre
Échap/q, Retour, drawer et sortie de l'application.
Le wrapper reader précise également que Home/End, flèches, suppression et texte
restent locaux au champ. Les événements bruts Notcurses demeurent privés à
l'adaptateur. Le comportement de destruction de `ncselector`, absent du
commentaire local du header, a été établi par sa documentation officielle
3.0.17, qui couvre aussi l'échec de création.
Cette revue est une revue de **design**, pas un audit d'une implémentation du
shell ni un gel PASS/FROZEN.
## Vérifications des artefacts
| Vérification | Résultat |
|---|---|
| Android : neuf vues statiques | Présentes ; IDs HTML uniques, références SVG et liens locaux résolus |
| Android : inspection visuelle | Rendu Firefox headless consulté à 360 px/100 % ; aperçu 320 px/200 % consulté pour le reflow |
| TUI texte | Six grilles : 120×35, 100×30, 80×24, 72×20, navigation 72×20, confirmation 72×20 |
| Bornes des grilles | Exactement la hauteur annoncée ; chaque ligne ≤ largeur annoncée en cellules Unicode ; deux lignes finales de footer non vides |
| TUI couleur | Rendu HTML du 120×35 inspecté ; il reprend les mêmes grilles, pas une capture de l'application actuelle |
| Scripts des deux visionneuses | Vérification de syntaxe `node --check` réussie |
| Code suivi par Git | `git diff --exit-code HEAD` et `git diff --cached --exit-code` passent |
| Espaces du diff suivi | `git diff --check` passe ; contrôle des nouveaux fichiers textuels fait séparément |
Contrastes calculés en sRGB pour les paires opaques proposées :
| Paire | Rapport |
|---|---:|
| Text / Base | 11,34:1 |
| Subtext 1 / Base | 9,26:1 |
| Subtext 1 / Surface 0 | 7,10:1 |
| Lavender / Surface 0 | 7,03:1 |
| Crust / Lavender | 10,48:1 |
| Error / Surface 0 | 5,43:1 |
| Warning / Surface 0 | 9,89:1 |
Ces résultats ne certifient pas tous les états d'accessibilité futurs.
Ils vérifient les couleurs et les artefacts du design ; les états réellement
composés, TalkBack, IME, polices système Android et palette de terminal seront
validés pendant l'implémentation.
Aucun build/test de production n'était requis par une modification de code :
il n'y en a pas eu. Aucun lancement de la TUI sur la base réelle, installation
Android ou essai MTP matériel n'est revendiqué. Aucun commit ni push.
## Sources de l'inspection
Sources du dépôt :
- [État courant](../../current_state.md), [contrat TUI](../../tui.md),
[contrat Android](../../android.md), [architecture](../../architecture.md).
- [TUI actuelle](../../../tui/src/tui.c),
[adaptateur terminal](../../../tui/src/terminal.c),
[clavier Trainlog](../../../tui/include/trainlog/terminal.h),
[interfaces DB existantes](../../../tui/include/trainlog/database.h).
- [Routes Android](../../../android/app/src/main/java/com/labfytools/trainlog/ui/TrainlogApp.kt),
[composants](../../../android/app/src/main/java/com/labfytools/trainlog/ui/TrainlogComponents.kt),
[thème](../../../android/app/src/main/java/com/labfytools/trainlog/ui/theme/TrainlogTheme.kt),
[dépendances](../../../android/app/build.gradle.kts).
- [Repository Android](../../../android/app/src/main/java/com/labfytools/trainlog/data/TrainlogRepository.kt),
[générateur](../../../android/app/src/main/java/com/labfytools/trainlog/ui/SessionGeneratorScreen.kt),
[historique/MAX](../../../android/app/src/main/java/com/labfytools/trainlog/ui/HistoryScreen.kt).
Provenance Notcurses : `pkg-config --modversion notcurses-core``3.0.17` ;
`pkg-config --libs notcurses-core``-lnotcurses-core` ;
`/usr/include/notcurses/version.h` et `/usr/include/notcurses/notcurses.h`.
Les signatures installées font foi, certaines synopsis HTML upstream étant
mal formées.
Références officielles consultées pour les choix de présentation/API :
- [Drawer Compose](https://developer.android.com/develop/ui/compose/components/drawer).
- [Material 3 et sa dépendance](https://developer.android.com/develop/ui/compose/designsystems/material3).
- [Accessibilité et minimum tactile 48 dp](https://developer.android.com/develop/ui/compose/accessibility/api-defaults).
- [Icônes vectorielles Android](https://developer.android.com/develop/ui/compose/graphics/images/material).
- [Palette Catppuccin](https://catppuccin.com/palette/).
- [Plans Notcurses](https://notcurses.com/notcurses_plane.3.html),
[selector 3.0.17 et propriété du plan](https://notcurses.com/notcurses_selector.3.html),
[reader et ses limites](https://notcurses.com/notcurses_reader.3.html).
## Arrêt demandé
`APP_SHELL_V1_DESIGN=READY_FOR_HUMAN_REVIEW`
La tranche s'arrête au design gate demandé. Une approbation explicite reste
nécessaire pour commencer l'implémentation de production.

View file

@ -0,0 +1,172 @@
<!doctype html><html lang="fr"><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>APP_SHELL_V1 — Maquettes TUI</title><style>
:root{color-scheme:dark}*{box-sizing:border-box}body{margin:0;padding:28px;background:#11111b;color:#cdd6f4;font:16px/1.5 system-ui,sans-serif}h1{font-size:24px;margin:0 0 8px}p{color:#bac2de;max-width:1000px}button{padding:10px 16px;background:#313244;color:#cdd6f4;border:1px solid #45475a;border-radius:6px;cursor:pointer}button[aria-pressed=true]{background:#b4befe;color:#11111b}nav{display:flex;flex-wrap:wrap;gap:8px;margin:20px 0}article{display:none}article.active{display:block}.frame{max-width:100%;overflow:auto;background:#11111b;padding:10px;border:1px solid #45475a;border-radius:8px;width:max-content}.terminal{font:14px/20px monospace;background:#1e1e2e;width:max-content}.line{height:20px;white-space:pre}.chrome{background:#181825}.side{display:inline-block;background:#181825;height:20px}.focus{color:#b4befe;background:#313244}.current{color:#b4befe}.muted{color:#bac2de}a{color:#b4befe}.small{font-size:14px}h2{font-size:18px}
</style><h1>Trainlog — shell Notcurses proposé</h1><p>Données fictives. Grilles exactes en cellules. Le cadre extérieur représente le terminal, pas un cadre à ajouter à l'application. La couleur distingue le focus de la rubrique active ; chaque état garde aussi un marqueur textuel.</p><nav>
<button data-target="s0" aria-pressed="true">120x35</button>
<button data-target="s1" aria-pressed="false">100x30</button>
<button data-target="s2" aria-pressed="false">80x24</button>
<button data-target="s3" aria-pressed="false">72x20</button>
<button data-target="s4" aria-pressed="false">72x20 — Navigation ouverte, contenu conservé dessous</button>
<button data-target="s5" aria-pressed="false">72x20 — Exemple de confirmation, footer réservé</button>
</nav>
<article id="s0" class="active"><h2>120x35 — Séances effectuées</h2><div class="frame"><div class="terminal">
<div class="line chrome"><span class=""> TRAINLOG Séance en cours : 3 exercices </span></div>
<div class="line chrome"><span class=""> Séances / Séances effectuées </span></div>
<div class="line "><span class="side"></span><span class=""> </span></div>
<div class="line "><span class="side"> Accueil │</span><span class=""> Séances effectuées </span></div>
<div class="line "><span class="side"></span><span class=""> / Rechercher par date ou type </span></div>
<div class="line "><span class="side current"> [Séances] │</span><span class=""> Toutes les séances · plus récentes en premier </span></div>
<div class="line "><span class="side"> Séance en cours │</span><span class=""> </span></div>
<div class="line "><span class="side"> Programmer │</span><span class=""> Date Type Exercices </span></div>
<div class="line "><span class="side"> Nouvelle manuelle │</span><span class=""> 09/09/2026 Entraînement 6 </span></div>
<div class="line "><span class="side current"> · Effectuées │</span><span class=""> 07/09/2026 Entraînement 6 </span></div>
<div class="line "><span class="side"></span><span class="focus"> &gt; 05/09/2026 Test max 4 </span></div>
<div class="line "><span class="side"> Exercices │</span><span class=""> 03/09/2026 Entraînement 6 </span></div>
<div class="line "><span class="side"> Équipements │</span><span class=""> 01/09/2026 Entraînement 6 </span></div>
<div class="line "><span class="side"> Statistiques │</span><span class=""> 29/08/2026 Entraînement 6 </span></div>
<div class="line "><span class="side"> Synchronisation │</span><span class=""> 27/08/2026 Entraînement 6 </span></div>
<div class="line "><span class="side"> Paramètres │</span><span class=""> 25/08/2026 Entraînement 6 </span></div>
<div class="line "><span class="side"></span><span class=""> 22/08/2026 Entraînement 6 </span></div>
<div class="line "><span class="side"></span><span class=""> 20/08/2026 Entraînement 6 </span></div>
<div class="line "><span class="side"></span><span class=""> 18/08/2026 Entraînement 6 </span></div>
<div class="line "><span class="side"></span><span class=""> 15/08/2026 Entraînement 6 </span></div>
<div class="line "><span class="side"></span><span class=""> ↓ Autres séances 3 / 24 </span></div>
<div class="line "><span class="side"></span><span class=""> </span></div>
<div class="line "><span class="side"></span><span class=""> ───────────────────────────────────────────────────────────────────────────────────────────── </span></div>
<div class="line "><span class="side"></span><span class=""> Sélection : 5 septembre · Test max </span></div>
<div class="line "><span class="side"></span><span class=""> Exercice : Pec Fly </span></div>
<div class="line "><span class="side"></span><span class=""> Équipement : Rear Delt / Pec Fly </span></div>
<div class="line "><span class="side"></span><span class=""> MAX explicite : 80 kg Entrée : ouvrir le détail complet </span></div>
<div class="line "><span class="side"></span><span class=""> </span></div>
<div class="line "><span class="side"></span><span class=""> </span></div>
<div class="line "><span class="side"></span><span class=""> </span></div>
<div class="line "><span class="side"></span><span class=""> </span></div>
<div class="line "><span class="side"></span><span class=""> </span></div>
<div class="line "><span class="side"></span><span class=""> </span></div>
<div class="line chrome"><span class=""> ↑↓ Choisir Entrée Détail / Rechercher e Modifier </span></div>
<div class="line chrome"><span class=""> F6 Navigation F7 Actions ? Aide Échap Retour </span></div>
</div></div></article>
<article id="s1" class=""><h2>100x30 — Séances effectuées</h2><div class="frame"><div class="terminal">
<div class="line chrome"><span class=""> TRAINLOG Séance en cours : 3 exercices </span></div>
<div class="line chrome"><span class=""> Séances / Séances effectuées </span></div>
<div class="line "><span class="side"></span><span class=""> </span></div>
<div class="line "><span class="side"> Accueil │</span><span class=""> Séances effectuées </span></div>
<div class="line "><span class="side"></span><span class=""> / Rechercher par date ou type </span></div>
<div class="line "><span class="side current"> [Séances] │</span><span class=""> Toutes les séances · plus récentes en premier </span></div>
<div class="line "><span class="side"></span><span class=""> </span></div>
<div class="line "><span class="side"> Exercices │</span><span class=""> Date Type Exercices </span></div>
<div class="line "><span class="side"> Équipements │</span><span class=""> 09/09/2026 Entraînement 6 </span></div>
<div class="line "><span class="side"> Statistiques │</span><span class=""> 07/09/2026 Entraînement 6 </span></div>
<div class="line "><span class="side"> Synchronisation │</span><span class="focus"> &gt; 05/09/2026 Test max 4 </span></div>
<div class="line "><span class="side"> Paramètres │</span><span class=""> 03/09/2026 Entraînement 6 </span></div>
<div class="line "><span class="side"></span><span class=""> 01/09/2026 Entraînement 6 </span></div>
<div class="line "><span class="side"></span><span class=""> 29/08/2026 Entraînement 6 </span></div>
<div class="line "><span class="side"></span><span class=""> 27/08/2026 Entraînement 6 </span></div>
<div class="line "><span class="side"></span><span class=""> 25/08/2026 Entraînement 6 </span></div>
<div class="line "><span class="side"></span><span class=""> 22/08/2026 Entraînement 6 </span></div>
<div class="line "><span class="side"></span><span class=""> 20/08/2026 Entraînement 6 </span></div>
<div class="line "><span class="side"></span><span class=""> 18/08/2026 Entraînement 6 </span></div>
<div class="line "><span class="side"></span><span class=""> 15/08/2026 Entraînement 6 </span></div>
<div class="line "><span class="side"></span><span class=""> ↓ Autres séances 3 / 24 </span></div>
<div class="line "><span class="side"></span><span class=""> </span></div>
<div class="line "><span class="side"></span><span class=""> ───────────────────────────────────────────────────────────────────────── </span></div>
<div class="line "><span class="side"></span><span class=""> Sélection : 5 septembre · Test max </span></div>
<div class="line "><span class="side"></span><span class=""> Exercice : Pec Fly </span></div>
<div class="line "><span class="side"></span><span class=""> Équipement : Rear Delt / Pec Fly </span></div>
<div class="line "><span class="side"></span><span class=""> MAX explicite : 80 kg Entrée : ouvrir le détail complet </span></div>
<div class="line "><span class="side"></span><span class=""> </span></div>
<div class="line chrome"><span class=""> ↑↓ Choisir Entrée Détail / Rechercher e Modifier </span></div>
<div class="line chrome"><span class=""> F6 Navigation F7 Actions ? Aide Échap Retour </span></div>
</div></div></article>
<article id="s2" class=""><h2>80x24 — Séances effectuées</h2><div class="frame"><div class="terminal">
<div class="line chrome"><span class=""> TRAINLOG Séance en cours : 3 exercices </span></div>
<div class="line chrome"><span class=""> Séances / Séances effectuées F6 Navigation </span></div>
<div class="line "><span class=""> </span></div>
<div class="line "><span class=""> Séances effectuées </span></div>
<div class="line "><span class=""> / Rechercher par date ou type </span></div>
<div class="line "><span class=""> Toutes les séances · plus récentes en premier </span></div>
<div class="line "><span class=""> </span></div>
<div class="line "><span class=""> Date Type Exercices </span></div>
<div class="line "><span class=""> 09/09/2026 Entraînement 6 </span></div>
<div class="line "><span class=""> 07/09/2026 Entraînement 6 </span></div>
<div class="line "><span class="focus"> &gt; 05/09/2026 Test max 4 </span></div>
<div class="line "><span class=""> 03/09/2026 Entraînement 6 </span></div>
<div class="line "><span class=""> 01/09/2026 Entraînement 6 </span></div>
<div class="line "><span class=""> 29/08/2026 Entraînement 6 </span></div>
<div class="line "><span class=""> 27/08/2026 Entraînement 6 </span></div>
<div class="line "><span class=""> 25/08/2026 Entraînement 6 </span></div>
<div class="line "><span class=""> 22/08/2026 Entraînement 6 </span></div>
<div class="line "><span class=""> 20/08/2026 Entraînement 6 </span></div>
<div class="line "><span class=""> ↓ Autres séances 3 / 24 </span></div>
<div class="line "><span class=""> </span></div>
<div class="line "><span class=""> Sélection : 5 septembre · Test max · 4 exercices </span></div>
<div class="line "><span class=""> </span></div>
<div class="line chrome"><span class=""> ↑↓ Choisir Entrée Détail / Rechercher e Modifier </span></div>
<div class="line chrome"><span class=""> F6 Navigation F7 Actions ? Aide Échap Retour </span></div>
</div></div></article>
<article id="s3" class=""><h2>72x20 — Séances effectuées</h2><div class="frame"><div class="terminal">
<div class="line chrome"><span class=""> TRAINLOG Séance en cours : 3 exercices </span></div>
<div class="line chrome"><span class=""> Séances / Séances effectuées F6 Navigation </span></div>
<div class="line "><span class=""> </span></div>
<div class="line "><span class=""> Séances effectuées </span></div>
<div class="line "><span class=""> / Rechercher par date ou type </span></div>
<div class="line "><span class=""> Toutes les séances · plus récentes en premier </span></div>
<div class="line "><span class=""> </span></div>
<div class="line "><span class=""> Date Type Exercices </span></div>
<div class="line "><span class=""> 09/09/2026 Entraînement 6 </span></div>
<div class="line "><span class=""> 07/09/2026 Entraînement 6 </span></div>
<div class="line "><span class="focus"> &gt; 05/09/2026 Test max 4 </span></div>
<div class="line "><span class=""> 03/09/2026 Entraînement 6 </span></div>
<div class="line "><span class=""> 01/09/2026 Entraînement 6 </span></div>
<div class="line "><span class=""> 29/08/2026 Entraînement 6 </span></div>
<div class="line "><span class=""> ↓ Autres séances 3 / 24 </span></div>
<div class="line "><span class=""> </span></div>
<div class="line "><span class=""> Sélection : 5 septembre · Test max </span></div>
<div class="line "><span class=""> Entrée : détail et résultats par exercice </span></div>
<div class="line chrome"><span class=""> ↑↓ Choisir Entrée Détail / Rechercher e Modifier </span></div>
<div class="line chrome"><span class=""> F6 Navigation F7 Actions ? Aide Échap Retour </span></div>
</div></div></article>
<article id="s4" class=""><h2>72x20 — Navigation ouverte, contenu conservé dessous</h2><div class="frame"><div class="terminal">
<div class="line chrome"><span class=""> TRAINLOG Séance en cours : 3 exercices </span></div>
<div class="line chrome"><span class=""> Séances / Séances effectuées F6 Navigation </span></div>
<div class="line "><span class=""> </span></div>
<div class="line "><span class=""> Navigation </span></div>
<div class="line "><span class=""> Rubrique active : Séances / Effectuées </span></div>
<div class="line "><span class=""> </span></div>
<div class="line "><span class=""> Accueil </span></div>
<div class="line "><span class="focus"> &gt; Séances </span></div>
<div class="line "><span class=""> Exercices </span></div>
<div class="line "><span class=""> Équipements </span></div>
<div class="line "><span class=""> Statistiques </span></div>
<div class="line "><span class=""> Synchronisation </span></div>
<div class="line "><span class=""> Paramètres </span></div>
<div class="line "><span class=""> </span></div>
<div class="line "><span class=""> </span></div>
<div class="line "><span class=""> Entrée ouvre la page de section. Toutes les rubriques sont nommées. </span></div>
<div class="line "><span class=""> </span></div>
<div class="line "><span class=""> </span></div>
<div class="line chrome"><span class=""> ↑↓ Choisir Entrée Ouvrir Échap Fermer </span></div>
<div class="line chrome"><span class=""> Navigation · retour à la liste conservé </span></div>
</div></div></article>
<article id="s5" class=""><h2>72x20 — Exemple de confirmation, footer réservé</h2><div class="frame"><div class="terminal">
<div class="line chrome"><span class=""> TRAINLOG Séance en cours : 3 exercices </span></div>
<div class="line chrome"><span class=""> Séances / Séance en cours </span></div>
<div class="line "><span class=""> </span></div>
<div class="line "><span class=""> Séance en cours · 3 exercices </span></div>
<div class="line "><span class=""> 1. Leg Press &gt; 2. Pec Fly 3. Abdominal crunch </span></div>
<div class="line "><span class=""> ┌─ Retirer cet exercice ? ───────────────────────────────────┐ </span></div>
<div class="line "><span class=""> │ │ </span></div>
<div class="line "><span class=""> │ Pec Fly · occurrence 2 │ </span></div>
<div class="line "><span class=""> │ │ </span></div>
<div class="line "><span class=""> │ Les séries de cette occurrence seront retirées. │ </span></div>
<div class="line "><span class=""> │ Le catalogue et les autres occurrences sont conservés. │ </span></div>
<div class="line "><span class=""> │ │ </span></div>
<div class="line "><span class="">&gt; [ Annuler ] [ Retirer ] │ </span></div>
<div class="line "><span class=""> │ │ </span></div>
<div class="line "><span class=""> └────────────────────────────────────────────────────────────┘ </span></div>
<div class="line "><span class=""> </span></div>
<div class="line "><span class=""> </span></div>
<div class="line "><span class=""> </span></div>
<div class="line chrome"><span class=""> Tab Choisir Entrée Confirmer Échap Annuler </span></div>
<div class="line chrome"><span class=""> Confirmation active · navigation suspendue </span></div>
</div></div></article>
<p class="small">Header : 2 lignes. Footer : 2 lignes. À 100 colonnes, sidebar de 22 cellules ; en compact, contenu sur toute la largeur. Les boutons ci-dessus changent seulement de maquette. <a href="tui_mockups.txt">Version texte</a> · <a href="proposal.md">Proposition complète</a></p><script>document.querySelectorAll('nav button').forEach(button=>button.addEventListener('click',()=>{document.querySelectorAll('nav button').forEach(b=>b.setAttribute('aria-pressed',String(b===button)));document.querySelectorAll('article').forEach(a=>a.classList.toggle('active',a.id===button.dataset.target));}));</script></html>

View file

@ -0,0 +1,180 @@
APP_SHELL_V1 — MAQUETTES TUI (DESIGN UNIQUEMENT)
Données fictives. Les coordonnées sont en cellules, pas en octets.
Chaque bloc contient exactement H lignes ; les espaces de fin sont omis.
La limite du terminal est implicite ; aucun grand cadre extérieur.
Lavender : focus (>). Surface 0 : sélection. Mantle : shell.
Les barres verticales en mode large représentent un séparateur discret.
=== 120x35 — Séances effectuées ===
```text
TRAINLOG Séance en cours : 3 exercices
Séances / Séances effectuées
Accueil │ Séances effectuées
│ / Rechercher par date ou type
[Séances] │ Toutes les séances · plus récentes en premier
Séance en cours │
Programmer │ Date Type Exercices
Nouvelle manuelle │ 09/09/2026 Entraînement 6
· Effectuées │ 07/09/2026 Entraînement 6
│ > 05/09/2026 Test max 4
Exercices │ 03/09/2026 Entraînement 6
Équipements │ 01/09/2026 Entraînement 6
Statistiques │ 29/08/2026 Entraînement 6
Synchronisation │ 27/08/2026 Entraînement 6
Paramètres │ 25/08/2026 Entraînement 6
│ 22/08/2026 Entraînement 6
│ 20/08/2026 Entraînement 6
│ 18/08/2026 Entraînement 6
│ 15/08/2026 Entraînement 6
│ ↓ Autres séances 3 / 24
│ ─────────────────────────────────────────────────────────────────────────────────────────────
│ Sélection : 5 septembre · Test max
│ Exercice : Pec Fly
│ Équipement : Rear Delt / Pec Fly
│ MAX explicite : 80 kg Entrée : ouvrir le détail complet
↑↓ Choisir Entrée Détail / Rechercher e Modifier
F6 Navigation F7 Actions ? Aide Échap Retour
```
=== 100x30 — Séances effectuées ===
```text
TRAINLOG Séance en cours : 3 exercices
Séances / Séances effectuées
Accueil │ Séances effectuées
│ / Rechercher par date ou type
[Séances] │ Toutes les séances · plus récentes en premier
Exercices │ Date Type Exercices
Équipements │ 09/09/2026 Entraînement 6
Statistiques │ 07/09/2026 Entraînement 6
Synchronisation │ > 05/09/2026 Test max 4
Paramètres │ 03/09/2026 Entraînement 6
│ 01/09/2026 Entraînement 6
│ 29/08/2026 Entraînement 6
│ 27/08/2026 Entraînement 6
│ 25/08/2026 Entraînement 6
│ 22/08/2026 Entraînement 6
│ 20/08/2026 Entraînement 6
│ 18/08/2026 Entraînement 6
│ 15/08/2026 Entraînement 6
│ ↓ Autres séances 3 / 24
│ ─────────────────────────────────────────────────────────────────────────
│ Sélection : 5 septembre · Test max
│ Exercice : Pec Fly
│ Équipement : Rear Delt / Pec Fly
│ MAX explicite : 80 kg Entrée : ouvrir le détail complet
↑↓ Choisir Entrée Détail / Rechercher e Modifier
F6 Navigation F7 Actions ? Aide Échap Retour
```
=== 80x24 — Séances effectuées ===
```text
TRAINLOG Séance en cours : 3 exercices
Séances / Séances effectuées F6 Navigation
Séances effectuées
/ Rechercher par date ou type
Toutes les séances · plus récentes en premier
Date Type Exercices
09/09/2026 Entraînement 6
07/09/2026 Entraînement 6
> 05/09/2026 Test max 4
03/09/2026 Entraînement 6
01/09/2026 Entraînement 6
29/08/2026 Entraînement 6
27/08/2026 Entraînement 6
25/08/2026 Entraînement 6
22/08/2026 Entraînement 6
20/08/2026 Entraînement 6
↓ Autres séances 3 / 24
Sélection : 5 septembre · Test max · 4 exercices
↑↓ Choisir Entrée Détail / Rechercher e Modifier
F6 Navigation F7 Actions ? Aide Échap Retour
```
=== 72x20 — Séances effectuées ===
```text
TRAINLOG Séance en cours : 3 exercices
Séances / Séances effectuées F6 Navigation
Séances effectuées
/ Rechercher par date ou type
Toutes les séances · plus récentes en premier
Date Type Exercices
09/09/2026 Entraînement 6
07/09/2026 Entraînement 6
> 05/09/2026 Test max 4
03/09/2026 Entraînement 6
01/09/2026 Entraînement 6
29/08/2026 Entraînement 6
↓ Autres séances 3 / 24
Sélection : 5 septembre · Test max
Entrée : détail et résultats par exercice
↑↓ Choisir Entrée Détail / Rechercher e Modifier
F6 Navigation F7 Actions ? Aide Échap Retour
```
=== 72x20 — Navigation ouverte, contenu conservé dessous ===
```text
TRAINLOG Séance en cours : 3 exercices
Séances / Séances effectuées F6 Navigation
Navigation
Rubrique active : Séances / Effectuées
Accueil
> Séances
Exercices
Équipements
Statistiques
Synchronisation
Paramètres
Entrée ouvre la page de section. Toutes les rubriques sont nommées.
↑↓ Choisir Entrée Ouvrir Échap Fermer
Navigation · retour à la liste conservé
```
=== 72x20 — Exemple de confirmation, footer réservé ===
```text
TRAINLOG Séance en cours : 3 exercices
Séances / Séance en cours
Séance en cours · 3 exercices
1. Leg Press > 2. Pec Fly 3. Abdominal crunch
┌─ Retirer cet exercice ? ───────────────────────────────────┐
│ │
│ Pec Fly · occurrence 2 │
│ │
│ Les séries de cette occurrence seront retirées. │
│ Le catalogue et les autres occurrences sont conservés. │
│ │
│ > [ Annuler ] [ Retirer ] │
│ │
└────────────────────────────────────────────────────────────┘
Tab Choisir Entrée Confirmer Échap Annuler
Confirmation active · navigation suspendue
```

View file

@ -215,7 +215,18 @@ Use this order:
explicit MAX may be displayed as historical context with
`explicit_max_present_no_numeric_prescription`, but supplies no numeric target.
3. Do not substitute zero, an invented average, another exercise's result,
a percentage of MAX, or a random default.
an automatically selected percentage of MAX, or a random default.
`PERCENT_MAX_INPUT_V1` is a separate user-directed calculator layered on the
editable proposal; it does not alter this frozen automatic policy. The user may
choose an integer from 1 through 100 only when the chronologically latest
explicit MAX for the exact exercise ID and exact applicable equipment ID is an
external-resistance result. The calculation is exactly
`MAX × percentage / 100`, with no automatic rounding or recommendation claim.
Assistance, absent equipment and incompatible contexts yield
`compatible_max_unavailable` and no target. Only the resulting
`target_weight_kg` enters an accepted draft; neither the percentage nor MAX
provenance is persisted.
Observed-load reuse has confidence **`uncertain`** for today's prescription.
"Successful" or "working" here means only that a qualifying repeated dose was
@ -228,7 +239,9 @@ progression over a more recent different performance. Its source must be visible
An explicit MAX is an occurrence-owned observed maximum result. Its model does
not contain a repetition count and is mutually exclusive with performed sets.
It therefore cannot be qualified as a measured 1RM from this record alone.
**No percentage prescription follows from an unqualified explicit MAX.**
**No automatic percentage prescription follows from an unqualified explicit
MAX.** An explicit user calculator selection is not a prescription generated by
the policy.
The original proposal's 50% fallback was rejected by the architecture review
because it contradicts this existing canonical boundary. The same absent-load
fallback applies to every goal. The 28-day actual-anchor cutoff remains a

View file

@ -0,0 +1,157 @@
# APP_SHELL_V1 implementation and validation record
## Status
```text
APP_SHELL_V1=IMPLEMENTED_AWAITING_VISUAL_REVIEW_2
```
This record describes the implemented cross-platform application shell. It
does not mark the checkpoint PASS or FROZEN. Its remaining final audit is the
human visual and accessibility review described below.
The subsequent repair and `PERCENT_MAX_INPUT_V1` tranche was validated by the
second automated review. `_2` records that repaired implementation state; it
does not claim that the remaining device/geometry review occurred. Repairs
include registered `/` dispatch, action-ID footer/F7 deduplication, explicit
lavender and textual selection states, F6/F7 focus restoration, reachable
Accueil/section hubs/catalogues, compact localized Android generator chips,
hidden internal IDs, generator duration honesty, latest-explicit-MAX display
semantics, and exact-context `%MAX`.
## Implemented contract
Both clients expose the same root information architecture:
```text
Accueil | Séances | Exercices | Équipements | Statistiques | Synchronisation | Paramètres
```
`Séances` contains the current draft, **Programmer une séance**, manual entry
and **Séances effectuées**. Existing body, twelve-month graph, analysis,
performance and explicit MAX views are relocated beneath `Statistiques`; no
new analytical measure, domain interpretation, name policy, schema or exchange
artifact was introduced. Exercise and equipment editing remains contextual to
operations already supported. There is no unsupported equipment-definition
editor.
The TUI has one run-scoped controller/event loop and persistent header,
content, sidebar when geometry permits, and two-line footer planes. It falls
back below 72x20, uses the compact shell from 72x20, displays a sidebar from
100x26, and an expanded sidebar from 120x32. The controller owns routes,
stable selections, focus, overlays and transient durability guards. Rendering
does not own persistence or synchronization. Navigation/redraw performs no
database write or sync. Overlay input has priority over aliases; local editors
have priority over shell aliases. F6 opens Navigation, F7 opens the same
action registry advertised by the footer, and closing an overlay restores its
saved focus and stable selection. The intentional shortcut changes recorded
by design §13 are `2/F2 Séances effectuées`, `5/F5 Mensurations`, F6
Navigation and F7 Actions.
Search and form fields use a bounded 200-byte UTF-8 buffer plus NUL. Invalid
or partial input is rejected without a partial mutation; cursor/delete honor
grapheme boundaries; Escape clears a non-empty query before closing it. Lists
preserve selection by stable ID. The terminal adapter maps Notcurses events to
Trainlog input semantics and consumes RELEASE events. Semantic RGB role tokens
are shared in intent with Android; color never carries state alone. Nerd Font
symbols are optional and every symbol has a text fallback.
Android uses a Material 3 drawer and `AppNavigationController` with typed
routes, caller-aware inline create/edit return, bounded history and derived
drawer selection. It retains the existing durable draft and blocks loss of
non-durable forms or a generator preview behind keep/discard resolution.
Routes themselves do not call repositories, finalization, schema work or sync.
The shell uses local vector resources, a sans-serif hierarchy and actions of at
least 48 dp; it has no runtime icon/parser dependency. Sync retains its
existing direction confirmation and diagnostic behavior.
The desktop custom-equipment page reader is additive UI infrastructure only:
it is a deterministic bounded, read-only API with 1..128 row capacity,
`capacity + 1` lookahead, explicit invalid/corruption errors and no schema
migration.
## Automated evidence
The final post-repair validation record is
`/tmp/trainlog-app-shell-v1/postrepair-validation/postrepair-validation.md`.
It recorded:
- normal `meson test -C build --print-errorlogs`: **46/46 passed**;
- ASan/UBSan `meson test -C build-asan --print-errorlogs`: **46/46 passed**;
- normal real-PTY shell validation: **100/100 checks passed**;
- ASan/UBSan real-PTY shell validation: **100/100 checks passed**, including
clean zero-status exit and no temporary-database mutation;
- five standalone strict C17 public-header checks passed (`app_shell`,
`database`, `terminal`, `theme`, `tui`);
- `git diff --check` passed.
The sanitizer PTY runs use
`ASAN_OPTIONS=use_sigaltstack=0:detect_leaks=1:halt_on_error=1` with fail-fast
UBSan stack traces. Leak detection remains enabled and no ASan/UBSan report was
emitted. The prior default alternate-signal-stack teardown failure was isolated
to the installed Notcurses v3.0.17/ASan compatibility boundary: the minimal
upstream reproduction records default behavior as 0/10 passes and the
upstream-prescribed setting as 10/10 passes. No Trainlog production behavior
was changed to accommodate that validation environment.
The earlier full validation also passed four JSON/import/policy/knowledge
validators and strict headers. Android
`JAVA_HOME=/usr/lib/jvm/java-17-openjdk ./gradlew testDebugUnitTest assembleDebug`
recorded 84 tests, 0 failures, 0 errors and one skipped external
`TRAINLOG_ANDROID_V9_FIXTURE` case. The debug APK is
`android/app/build/outputs/apk/debug/app-debug.apk`.
Launch the desktop UI with:
```bash
cd /home/fy59/Documents/trainlog
./build/tui/trainlog
```
## Remaining human review
No emulator was available and the debug APK was not installed or exercised on
the user's daily phone. The pending human checklist is therefore explicit:
- TUI at 72x20, 80x24, 100x25, 100x30, 120x31 and 120x35, including compact
Navigation, F6/F7, route/focus restoration, text fallbacks and resize;
- Android at 320, 360, 393 and 412 dp, including 100%, 130% and 200% text
scale, long text, drawer and scroll restoration;
- Android IME forms and durable-draft keep/discard guards;
- TalkBack navigation; and
- manual connected-device MTP behavior where applicable.
These unperformed checks are why APP_SHELL_V1 remains
`IMPLEMENTED_AWAITING_VISUAL_REVIEW_2`, rather than PASS or FROZEN.
## Bounded review repairs
The full TUI delta review initially found two bounded issues: compact resize
did not preserve a dispatchable Navigation focus target, and the public
AppShell API contract comments were incomplete. The focused repair review
verified both corrections: compact Navigation receives sidebar focus after a
resize, Enter/F6 and reverse Tab reach the rendered control, overlays restore
focus, and the public API now documents bounds, ownership and result behavior.
The Android review found one bounded callback path that could bypass the root
navigation guard when a durable draft already existed. The repair routed
production host transitions through `AppNavigationController`; its regression
keeps the generator route and transient proposal until an explicit keep or
discard decision.
The independent final audit found one blocking class only: two canonical
statements still described the retired per-page `◆ TRAINLOG ◆` plaque. This
documentation-only repair synchronizes `docs/current_state.md` and
`docs/architecture.md` with the implemented `AndroidAppShell` Material 3
`Scaffold`/`TopAppBar` and content host. The bounded documentation-repair
review passed (`/tmp/trainlog-app-shell-v1/final-doc-review.md`); no second
deep audit is required.
Normal closeout validation also passed
(`/tmp/trainlog-app-shell-v1/closeout-validation/closeout-validation.md`):
46/46 desktop tests, the four JSON/import/knowledge/policy validators, Android
84 tests with 0 failures, 0 errors and one external-fixture skip, all 35
catalog/fixture hashes, the unchanged APK hash, and an empty index. The
automated portion is closed. Human visual/accessibility/device review remains
the sole outstanding boundary, so APP_SHELL_V1 remains
`IMPLEMENTED_AWAITING_VISUAL_REVIEW_2` rather than PASS or FROZEN.

View file

@ -0,0 +1,72 @@
# APP_SHELL_V1 Android retrospective review
## Scope
This record covers the settled Android portion of APP_SHELL_V1 only. It records
the resulting shell and the evidence available on 10 September 2026; it does
not declare the cross-platform APP_SHELL_V1 checkpoint PASS or FROZEN.
The Android shell now has seven Material 3 drawer destinations: Accueil,
Séances, Exercices, Équipements, Statistiques, Synchronisation and Paramètres.
Typed routes carry stable detail IDs and caller context. Catalogue-first
exercise and equipment workflows supply dedicated detail routes; create and
edit retain the existing domain operations. The exercise detail presents
persisted profile/zones, available knowledge with explicit uncertainty and
sources, compatible equipment, and any local explicit MAX. Equipment detail
reports the existing supplied/personal definition or an unknown reference.
Navigation is owned by `AppNavigationController`. It derives drawer selection
from the route, maintains bounded route history, and returns inline creation to
its declared caller exactly once. Pending unaccepted generator work and dirty
non-durable forms require keep/discard resolution before navigation. Keeping
retains the transient values; discard clears only the initiating transient
state. Navigation itself makes no repository, synchronization, finalization,
schema, format, naming, catalog, knowledge, planned/actual, MAX, or equipment
semantic change.
The active v11 draft and active V3 completed-session exchange remain
repository-owned and unchanged. `SaveableStateHolder` keeps small route/list
state with a 16-route bound; the root ViewModel retains UI transients through
Activity recreation. It serializes neither a domain session nor a generated
proposal, so process death resumes only the durable active draft.
## Findings and repairs
Initial Android review found that production callbacks in `TrainlogApp` could
bypass the root navigation controller. In the concrete
`existing_active_draft` generator result, that bypass could leave a dirty
proposal without presenting the required keep/discard choice. The repair
routed all production host transitions through controller wrappers and added a
Robolectric Compose regression that invokes the actual acceptance callback.
The final bounded callback review closed with that finding repaired. The
regression proves that an existing durable draft keeps the route at the
generator while a pending guard is installed; choosing keep reaches Séances
while retaining preview and raw input, and the initialized SQLite database
remains byte-identical. The review also confirmed that only explicit discard
clears generator state and that no raw route mutation remains in the production
root host.
The second repair review also confirmed that compact generator choices retain
their localized user-facing labels without exposing internal identifiers. Its
`%MAX` context shows the chronologically latest explicit compatible MAX for the
exact exercise and external-resistance equipment identity; assistance and an
incompatible or absent context remain unavailable. This is a read-only user
calculator: it makes no recommendation and acceptance persists only the
resulting target weight, not a percentage or MAX provenance.
## Validation and remaining limits
`JAVA_HOME=/usr/lib/jvm/java-17-openjdk ./gradlew testDebugUnitTest assembleDebug`
completed successfully: 84 tests total, 83 passed, 0 failures, 0 errors, and
1 skipped. The skipped external historical-fixture case requires
`TRAINLOG_ANDROID_V9_FIXTURE`. The debug APK is 12,649,975 bytes with SHA-256
`ddb1221d25db5be60eb2261d4b1dcf0fb7446e2a780c67aae862f37c15b7396d`; its nine
shell icons and ten referenced catalogue assets matched their expected bytes.
`git diff --check -- android` passed during Android validation.
No emulator was available and the debug APK was not installed or exercised on
the connected daily phone. Human review remains pending at 320, 360, 393 and
412 dp; 100%, 130% and 200% text scale; IME/drawer/form interactions; long
translated and source text; scroll restoration; and TalkBack. These limits
prevent a human visual/accessibility completion claim.

View file

@ -0,0 +1,67 @@
# Exercise alias sync canonicalization audit
Date: 2026-09-11
Scope: actual Android/desktop sync artifacts and the production import/export
paths which carry an exercise identity. This is an engineering review artifact,
not a format or architecture change.
## Identity rule
`exercise_aliases` contains flattened compatibility identities. A source ID is
accepted as input identity and resolves to its live canonical exercise before
identity/conflict comparison. It must not recreate an exercise row. Catalogue,
session, body-zone, and equipment exports publish live canonical exercise IDs;
the alias companion is the only output which publishes retired source IDs.
## Surface matrix
| Direction / artifact | Producer or consumer | Incoming/working treatment | Exported identity | Audit result |
|---|---|---|---|---|
| Android → PC `trainlog-exercise-aliases-v1.json` | `tools/import_exercise_aliases.py` | Validates sorted, flattened A→B mappings; moves references from a compatible live A to B and deletes A before persisting the alias | n/a | Canonical; establishes durable identity before other imports |
| Android → PC `trainlog-mobile-export-v1/v2/v3.json` exercise catalogue | `tools/import_mobile_export.py:import_exercises` | Resolves a persisted source alias before normalized-name reconciliation and builds raw-input-ID → canonical-ID mapping | n/a | Canonical; no source resurrection |
| Android → PC mobile session occurrences | `tools/import_mobile_export.py:import_sessions` and `session_semantically_matches` | Uses the catalogue mapping before occurrence insertion and replay identity/content comparison | n/a | Canonical; preserves session ID and entry ID |
| Android → PC `trainlog-exercise-body-zones-v1.json` | `tools/import_exercise_body_zones.py` | Resolves each input ID to one exercise row, groups equal A/B claims by canonical row, rejects divergent claims before writes | n/a | Canonical and order-independent |
| Android → PC `trainlog-equipment-associations-v2.json` | `tools/import_equipment_associations.py` | **Previously compared raw incoming A with stored B.** Now resolves both through the flattened alias before corroborating `(session_id, entry_id)`; distinct live canonical IDs and equipment divergence remain conflicts | n/a | Repaired |
| PC → Android `trainlog-exercise-aliases-v1.json` | `tools/export_exercise_aliases.py` | Reads the flattened compatibility table | A→B by definition | Correct exception: sole retired-ID publication |
| PC → Android `trainlog-pc-catalog-v1.json` | `tools/export_pc_catalog.py` | Reads live `exercises` rows after merge removes A | B | Canonical |
| PC → Android `trainlog-pc-mobile-export-v2/v3.json` catalogue and occurrences | `tools/export_pc_mobile.py` | Reads live exercise rows and occurrence FKs joined to those rows | B | Canonical; entry/session identities unchanged |
| Bidirectional `trainlog-exercise-body-zones-v1.json` export | `tools/export_exercise_body_zones.py` | Reads body-zone state joined to live exercises | B | Canonical |
| Bidirectional `trainlog-equipment-associations-v2.json` export | `tools/export_equipment_associations.py` | Reads occurrences joined to live exercises | B | Canonical; equipment identity unchanged |
| PC → Android alias companion | `TrainlogRepository.applyExerciseAliasesJson` | Imports/merges aliases before the catalogue; moves session/draft/metadata references and removes retired source | n/a | Canonical; no source resurrection |
| PC → Android catalogue | `TrainlogRepository.applyPcCatalogJson` | Calls `resolveExerciseId` before lookup/reconciliation | n/a | Canonical |
| PC → Android mobile session V2/V3 | `TrainlogRepository.applyPcMobileExportJson` | Resolves catalogue and occurrence exercise IDs before row lookup, replay checks, and resumed-MAX checks | n/a | Canonical; entry/session identities unchanged |
| PC → Android body-zone companion | `TrainlogRepository.applyExerciseBodyZonesJson` | Resolves input ID before row/baseline lookup | n/a | Canonical |
| PC → Android equipment companion V1/V2 | `TrainlogRepository.applyPcEquipmentAssociationsJson` | Resolves input ID before occurrence lookup/corroboration | n/a | Canonical; genuine exercise/equipment conflicts rejected |
| Android → PC mobile/session, body-zone, and equipment producers | `TrainlogRepository.buildMobileExportV3Json`, `buildExerciseBodyZonesJson`, and `buildEquipmentAssociationsJson` | Read live exercise rows referenced by catalogue/occurrence FKs | B | Canonical |
| Android → PC alias producer | `TrainlogRepository.buildExerciseAliasesJson` | Reads the flattened compatibility table | A→B by definition | Correct exception: sole retired-ID publication |
## Root cause and repaired precondition
The desktop equipment V2 consumer looked up the occurrence correctly by stable
`(session_id, entry_id)`, but then compared `exists[0] != exercise_id` using raw
exercise-ID strings. Its only fallback required a selected V2 mobile proof and
the incoming exercise row to have disappeared. Production `trainlog_sync_run()`
does not pass that optional proof to this importer, and current Android publishes
V3, so a valid persistent A→B alias still failed as `conflit exercice
association`.
The repaired precondition is equality after durable alias resolution. The V2
proof fallback remains bounded to historic reconciliation without a persistent
alias. No import path changes a session ID, entry ID, equipment ID, occurrence,
schema, or artifact version.
## Regression coverage
`tests/test_equipment_associations_exchange.py` covers direct A→B acceptance
without V2 proof, no source recreation, and rejection of an unrelated live
canonical exercise.
`tui/tests/test_sync_body_zone_wiring.c` drives the real
`trainlog_sync_run(TRAINLOG_SYNC_BIDIRECTIONAL)` orchestration against mocked
MTP and temporary storage. It uses live
`ex_2d488c08-194c-4051-a3c9-34471646c1d3` plus persistent alias
`ex_43c7375f-934c-4650-930c-45807d2f2929` → live ID, imports historical A/B
session and companion rows, replays the run, checks both stable entries remain
bound to B, and verifies that all non-alias outbound artifacts contain B and do
not contain A.

View file

@ -33,7 +33,7 @@ BODY_ZONES_V1=PASS
BODY_ZONE_SYNC_V1=PASS
BODY_ZONES_ANDROID_DEVICE_VALIDATION=PASS
DESKTOP_TESTS=45/45 PASS (latest validated checkpoint)
DESKTOP_TESTS=47/47 PASS (latest validated checkpoint)
TUI_NOTCURSES_V1=PASS
NCURSESW_REMOVED_FROM_ACTIVE_TUI=PASS
NOTCURSES_TRUECOLOR_THEME=PASS
@ -212,6 +212,11 @@ It does not create reusable templates, a planned-session sync product surface,
or a multi-session program. The one deep final audit found repairable gaps;
its bounded repairs, review, and final validation matrix passed.
`PERCENT_MAX_INPUT_V1` is an explicit calculator layered on editable V1 targets;
it does not change automatic selection policy. Any broader multi-session,
periodized, or program-producing work is deferred to a future
`SESSION_GENERATOR_V2` and is not present today.
## Session templates v1
Templates are built from the real exercise catalog and planning model.
@ -405,6 +410,17 @@ BACKUP_EXPORT_V1
This order is canonical until explicitly revised.
## APP_SHELL_V1 visual-review boundary
`APP_SHELL_V1=IMPLEMENTED_AWAITING_VISUAL_REVIEW_2`. The implemented shell does
not open STATS_V1, EXERCISE_NAMING_V1, a schema migration, or a new
synchronization feature. The remaining checkpoint is a human visual and
accessibility review of the existing seven-root shell: TUI at 72x20, 80x24,
100x25, 100x30, 120x31 and 120x35; Android at 320, 360, 393 and 412 dp, with
large system font, IME form interaction, durable-draft leave guards and
TalkBack. The `_2` suffix records the completed automated repair review only;
its human-review outcome is not recorded here in advance.
## Permanent constraints
Do not regress to:

View file

@ -49,16 +49,27 @@ Framework folder grant.
| Android -> PC | `trainlog-mobile-equipment-definitions-v1.json` | `trainlog-equipment-definitions` v1 |
| Android -> PC | `trainlog-equipment-associations-v2.json` | `trainlog-equipment-associations` v2 |
| Android -> PC | `trainlog-exercise-body-zones-v1.json` | `trainlog-exercise-body-zones` v1 |
| Android -> PC | `trainlog-exercise-aliases-v1.json` | `trainlog-exercise-aliases` v1 |
| PC -> Android | `trainlog-pc-catalog-v1.json` | `trainlog-pc-catalog` v1 |
| PC -> Android | `trainlog-pc-equipment-definitions-v1.json` | `trainlog-equipment-definitions` v1 |
| PC -> Android | `trainlog-pc-mobile-export-v3.json` | `trainlog-mobile-export` v3 (active) |
| PC -> Android | `trainlog-equipment-associations-v2.json` | `trainlog-equipment-associations` v2 |
| PC -> Android | `trainlog-exercise-body-zones-v1.json` | `trainlog-exercise-body-zones` v1 |
| PC -> Android | `trainlog-exercise-aliases-v1.json` | `trainlog-exercise-aliases` v1 |
| Android -> PC agent | `trainlog-sync-request-v1.json` | `trainlog-sync-request` v1 |
| PC agent -> Android | `trainlog-sync-receipt-v1.json` | `trainlog-sync-receipt` v1 |
No SQLite file is transferred.
`trainlog-exercise-aliases` v1 is the separate EXERCISE_MERGE_V1 identity
companion. Its root contains exactly `format`, `version`, and `aliases`; every
entry contains exactly `source_exercise_id` and `canonical_exercise_id` using
lowercase UUIDv4 creator IDs. Entries are bytewise source-sorted, sources are
unique, mappings are collapsed (no target may also be a source), and the
artifact is bounded to 4096 entries and 1 MiB. It changes no mobile-export V3
field semantics. Import occurs before snapshot reconciliation; exports contain
only live canonical exercise IDs.
Android scoped storage can preserve a prior MTP-created object and create a
new artifact with the provider collision suffix, for example
`trainlog-mobile-export-v3 (N).json` or

View file

@ -70,6 +70,10 @@ database
catalog
equipment_catalog
body_zones
training_knowledge
session_generation
session_generation_policy_validation
training_context
custom_equipment
session_detail
duration
@ -89,8 +93,10 @@ variable_sets
schema_v5_migration
schema_v9_migration
schema_v7_migration
timestamp_validation
mobile_import_variable_sets
mobile_import_multi_occurrence
session_exchange_v3
equipment_associations_exchange
equipment_definitions_exchange
exercise_reconciliation
@ -105,9 +111,12 @@ max_sync
body_analytics
terminal_input_event_type_policy
tui_workflows
app_shell
```
Validated current suite:
The current desktop suite, including APP_SHELL_V1 production-transition
coverage, is 47/47. The following generator-specific checkpoint counts remain
historical evidence:
```text
45/45 Meson tests PASS
@ -227,6 +236,34 @@ meson test -C build --print-errorlogs
Strict warning flags remain active. Do not weaken warnings to make a change pass.
## APP_SHELL_V1 production-transition coverage
The active desktop suite has 46 tests. `app_shell` verifies layout thresholds,
route/history bounds, overlays and focus restoration, bounded UTF-8 search and
form input, stable-ID list selection, shared action ordering, and leave guards.
`tui_workflows` covers the production single event-loop routes and controller
handoffs: sessions/generator, exercises, equipment, statistics/body/MAX,
settings, and confirmed synchronization. `custom_equipment` covers the
bounded deterministic read-only equipment page reader, including pagination,
invalid arguments, offsets and corrupt values. This coverage replaces former
nested-screen-loop workflow claims.
The APP_SHELL PTY validation exercises six TUI sizes—72x20, 80x24, 100x25,
100x30, 120x31 and 120x35—plus help, search clear/close, F6/F7, compact focus,
resize/overlay restoration, clean exit, and navigation with no temporary
database write. Normal and ASan/UBSan Meson suites each passed 47/47; the
current normal and sanitizer real-PTY runs each passed 100/100 checks. The
sanitizer run used the upstream-prescribed Notcurses compatibility setting
`ASAN_OPTIONS=use_sigaltstack=0:detect_leaks=1:halt_on_error=1` and reported
no ASan/UBSan diagnostics.
Android unit/assembly evidence records 84 tests, 0 failures, 0 errors and one
external `TRAINLOG_ANDROID_V9_FIXTURE` skip. Required human checks remain:
the six TUI sizes above; Android widths 320, 360, 393 and 412 dp; large system
font; IME forms; durable-draft leave guards; and TalkBack. No emulator was
available; the daily installed application was not installed over or exercised
by instrumentation, so these are not marked visually passed.
## 6. Android build
When Android code changes:
@ -370,8 +407,16 @@ Coverage proves:
- no-load max tests compare actual reps/duration;
- external working loads round to the configured increment;
- working-load percentages reject assistance.
- `%MAX` target calculators use the exact unrounded formula, enforce 1..100,
exact equipment identity and external resistance, and reject assistance;
- Android manual target-plan tests prove that calculation is read-only, actual
set weights remain unchanged, existing target dose/rest survive direct-kg
edits, and none removes the plan target;
- the real TUI `/` path resolves through the registered action, focuses search,
filters live, then follows clear-before-close Escape semantics;
- action stable identifiers are unique, preventing footer/F7 duplication.
Validated current normal suite:
Historical validation checkpoint (the current desktop suite is 47/47):
```text
39/39 Meson tests PASS
@ -396,7 +441,7 @@ Coverage includes:
- missing required circumference handling;
- invalid estimation-profile rejection.
Validated current normal suite:
Historical validation checkpoint (the current desktop suite is 47/47):
```text
39/39 Meson tests PASS
@ -405,10 +450,14 @@ Validated current normal suite:
## 13. Android session draft v1
Android schema v4 introduced one durable active draft; the current additive
chain reaches schema v10 without clearing completed history or the draft. The
current `testDebugUnitTest` suite and `assembleDebug` pass. Host coverage
includes exercise
shapes and raw partial text, fresh repository restore, remove/discard, atomic
chain reaches schema v12 without clearing completed history or the draft. The
explicit v10 -> v11 migration adds optional planning metadata while preserving
existing rows with `load_mode=none`, zero rest and NULL targets. The
v11 -> v12 migration adds the durable flattened exercise-alias table. Its exact
physical-v11 fixture compares every pre-existing table cell before and after
migration, requires the new alias table to be empty, and checks foreign keys.
Host coverage includes exercise shapes and raw partial text, fresh repository
restore, remove/discard, atomic
finalization and repeated-finalize rejection, rollback, catalog reconciliation,
missing-selection recovery, explicit DB-open failure, historical migration,
equipment selection and occurrence identity.
@ -448,7 +497,7 @@ Sanitizers: clang ASan/UBSan Meson build and test invocation
Executed Body Zones V1 evidence is recorded after each closeout run: Android
`testDebugUnitTest` 44/44 with the retained real v9 fixture enabled and
`assembleDebug`, 39/39 Meson tests, valid and invalid JSON checks,
`assembleDebug`, a historical 39/39 Meson checkpoint, valid and invalid JSON checks,
import-contract checks, and the ASan/UBSan Meson suite. Device installation and
installed Android-store migration remain explicit hardware steps and are never
inferred from host tests.

View file

@ -8,33 +8,66 @@ synchronization.
Desktop SQLite is the canonical long-term history.
## 2. Navigation
## 2. APP_SHELL_V1 navigation
Large-layout primary navigation:
The persistent shell has seven root sections:
```text
0 Accueil
1 Séance
2 Historique
3 Exercices
4 Équipements
5 Corps
6 Sync
Accueil | Séances | Exercices | Équipements | Statistiques | Synchronisation | Paramètres
```
Direct shortcuts include the matching function keys where implemented.
`Séances` contains **Séance en cours**, **Programmer une séance**, **Nouvelle
séance manuelle**, and **Séances effectuées**. The latter replaces the former
top-level Historique entry. Existing mensurations, body graphs/analysis,
exercise performance and explicit MAX views are reached through
`Statistiques`; this relocation adds no statistic or analytical interpretation.
The geometry policy is exact:
```text
under 72x20 small-terminal fallback
72x20 or larger usable compact shell with temporary Navigation control
100x26 or larger visible sidebar
120x32 or larger expanded sidebar
```
The terminal owns a run-scoped application context with persistent header,
content, sidebar when present, and two-line footer planes. It has a single
event loop; planes are rendering resources, not application state. Resize
recomputes the layout and preserves the route, stable list selection and a
visible equivalent focus target. A sidebar focus becomes the compact
Navigation control when the sidebar disappears.
Common controls:
```text
↑ ↓ list navigation
Enter open/activate
Tab change focus on multi-zone pages
Esc / b return or cancel
0 / Home dashboard
Tab / Shift+Tab cycle rendered focus targets
F6 open Navigation
F7 open the route's action registry
Esc / b return, cancel, or close/restore an overlay
0 / Home Accueil
1/F1 … 5/F5 manual session, completed sessions, exercises, equipment, mensurations
6 Synchronisation
q quit from the application shell
```
The root aliases and contextual actions are built from one action registry:
the footer and F7 therefore advertise the same enabled actions that dispatch.
The intentional APP_SHELL_V1 shortcut changes are the replacement of the old
top-level Historique/Corps aliases by `2/F2 Séances effectuées` and
`5/F5 Mensurations`, F6 Navigation and F7 Actions, as approved in design
§13. A route alias is ignored while an overlay is active; a local editor owns
its keys before shell aliases.
On an exercise detail, `F7 Actions` exposes `Fusionner avec…`. The current
exercise is the source; a bounded UTF-8 search overlay selects the canonical
target. A second overlay previews the explicit source → canonical direction
and the occurrence-owned data preserved by the transaction. Profile or
primary-zone conflicts leave both exercises untouched and show a specific
result. After success, the canonical target remains selected.
Minimum terminal size:
```text
@ -52,10 +85,13 @@ Notcurses provides a true-color Catppuccin-derived dark palette: background
warning, error, muted, and graph roles. Unicode frames and visible selection
markers enhance presentation without becoming application semantics.
The backend owns one standard plane for a run, translates terminal input into
Trainlog-owned keys, accepts complete UTF-8 code points in prompts, and
re-queries dimensions while rendering so the 72x20 minimum/fallback recovers
after a resize.
The backend owns run-scoped planes, translates terminal input into
Trainlog-owned keys, and accepts complete UTF-8 code points in bounded shell
editors without a runtime parser or `ncreader`. Search/form storage is capped
at 200 UTF-8 bytes plus NUL, rejects invalid or partial code points without
partial mutation, and keeps grapheme boundaries while moving or deleting.
Escape clears a non-empty search before closing an empty search. Filtered lists
retain selection by stable ID and report bounded page availability.
Input lifecycle is handled at that boundary: legacy/unknown terminal events,
Notcurses PRESS events, and deliberate auto-REPEAT events become one logical
@ -76,8 +112,9 @@ muted
graph series
```
Focused frames use the warning role for border/title without recoloring all
content.
Focused frames use the focus semantic role without recoloring all content.
The palette uses synchronized semantic RGB role tokens; an optional Nerd Font
may improve symbols, while text fallbacks are mandatory.
## 4. Exercise catalog
@ -174,6 +211,23 @@ warnings visible. Accepting a preview builds the ordinary normal session draft
with zero actual rows and enters the existing editor. The existing completion
guard still requires actual rows for every SETS exercise.
The preview and ordinary plan editor preserve direct kg and no-target entry and
also expose a `%MAX` calculator. It accepts only integers 1..100 and only the
chronologically latest explicit MAX for the exact exercise/equipment external-
resistance context. Assistance is unavailable. The formula is
`MAX × percentage / 100`; it is not a recommendation, and only the resulting
`target_weight_kg` is retained when the draft is accepted. In the generator,
`u` restores automatic V1 load qualification, `%` selects the calculator and
`x` selects no numeric target.
The preview labels these outcomes `user_selected_max_percentage` or
`compatible_max_unavailable` and clears a calculated target if its equipment
context changes.
Generator duration is labelled as a target alongside the estimate. A
meaningful shortfall is reported without padding, and V1 states explicitly that
it generates neither warm-up nor cool-down. Multi-session/program work remains
future `SESSION_GENERATOR_V2`; it is not implemented by these controls.
## 6. Session history and editing
History is keyboard navigable.
@ -201,8 +255,8 @@ catalog.
## 7. Equipment
`4 Équipements / F4` provides supplied-equipment browsing, search, detail, and
custom-equipment creation and selection. Supplied definitions are generated
`Équipements` (direct alias `4/F4`) provides supplied-equipment browsing,
search, detail, and custom-equipment creation and selection. Supplied definitions are generated
from `catalog/equipment-v1.json`; user-created definitions persist in desktop
SQLite and synchronize separately through definitions V1.
@ -216,7 +270,7 @@ visibly distinct; `i` opens the resolved equipment detail.
## 8. Body tracking
`5 Corps / F5` provides:
`Statistiques → Mensurations` (direct alias `5/F5`) provides:
- newest-first body observations;
- detail and correction;
@ -227,9 +281,10 @@ visibly distinct; `i` opens the resolved equipment detail.
Editing preserves observation identity, timestamp, and optional session link.
## 9. Dashboard
## 9. Statistics and dashboard
The dashboard includes a rolling 12-month normalized body graph.
The rolling 12-month normalized body graph has moved from the dashboard to
`Statistiques → Mensurations`; its data rules are unchanged.
Rules include:
@ -238,9 +293,9 @@ Rules include:
- no zero fill;
- no interpolation;
- when multiple observations exist in one month, the last visible monthly value
is used for the compact dashboard graph.
is used for the compact twelve-month graph.
Detailed raw observations remain in `Corps`.
Detailed raw observations remain in `Statistiques → Mensurations`.
## 10. Exercise performance
@ -443,7 +498,7 @@ performance.
## 17. Body analytics
`5 Corps` adds:
`Statistiques → Mensurations` adds:
```text
v analyse corporelle

View file

@ -71,6 +71,51 @@ def mapping(db):
return rows, baseline
def write_companion(path, entries):
path.write_text(json.dumps({
"format": "trainlog-exercise-body-zones", "version": 1,
"generated_at": "2032-01-01T00:00:00+00:00",
"exercises": entries,
}), encoding="utf-8")
def entry(exercise_id, primary="back", secondary=None):
return {
"exercise_id": exercise_id,
"primary_zone_id": primary,
"secondary_zone_ids": ["arms"] if secondary is None else secondary,
}
def add_alias(db, source_id, canonical_id):
connection = sqlite3.connect(db)
connection.execute(
"CREATE TABLE exercise_aliases(source_exercise_id TEXT PRIMARY KEY, "
"canonical_exercise_id TEXT NOT NULL)"
)
connection.execute(
"INSERT INTO exercise_aliases VALUES(?,?)", (source_id, canonical_id)
)
connection.execute("PRAGMA user_version=12")
connection.commit()
connection.close()
def assert_alias_identity(db, source_id, canonical_id):
connection = sqlite3.connect(db)
assert connection.execute(
"SELECT COUNT(*) FROM exercises WHERE exercise_id=?", (source_id,)
).fetchone()[0] == 0
assert connection.execute(
"SELECT canonical_exercise_id FROM exercise_aliases WHERE source_exercise_id=?",
(source_id,),
).fetchone() == (canonical_id,)
assert connection.execute(
"SELECT COUNT(*) FROM exercise_body_zones"
).fetchone()[0] == 2
connection.close()
def main():
with tempfile.TemporaryDirectory(prefix="trainlog-body-zone-sync-") as temporary:
root = Path(temporary)
@ -164,13 +209,7 @@ def main():
remote_id = "ex_33333333-3333-4333-8333-333333333333"
database(alias_db, local_id, with_mapping=False)
alias_artifact = root / "alias-zones.json"
alias_artifact.write_text(json.dumps({
"format": "trainlog-exercise-body-zones", "version": 1,
"generated_at": "2032-01-01T00:00:00+00:00", "exercises": [{
"exercise_id": remote_id, "primary_zone_id": "back",
"secondary_zone_ids": ["arms"],
}],
}), encoding="utf-8")
write_companion(alias_artifact, [entry(remote_id)])
proof = root / "proof.json"
proof.write_text(json.dumps({
"format": "trainlog-mobile-export", "version": 2,
@ -179,12 +218,74 @@ def main():
"recording_mode": "sets", "tracking_mode": "reps", "data_fields": 0,
}], "sessions": [], "body_observations": [],
}), encoding="utf-8")
# Explicitly retained V2 evidence coalesces source+canonical claims by
# the same exact-payload rule as a durable alias.
write_companion(alias_artifact, [entry(remote_id), entry(local_id)])
alias_import = run(
IMPORT, alias_artifact, alias_db, "--mobile-export", proof,
)
assert alias_import.returncode == 0, alias_import.stdout + alias_import.stderr
assert alias_import.returncode == 0 and "zones_updated=1" in alias_import.stdout, (
alias_import.stdout + alias_import.stderr
)
assert mapping(alias_db) == ([('back', 'primary'), ('arms', 'secondary')], "back|arms")
# Once EXERCISE_MERGE_V1 has persisted the retired creator identity,
# the companion resolves it without requiring a mobile snapshot/name
# proof. This is the normal post-merge transit order.
persistent_alias_db = root / "persistent-alias.db"
database(persistent_alias_db, local_id, with_mapping=False)
add_alias(persistent_alias_db, remote_id, local_id)
write_companion(alias_artifact, [entry(remote_id), entry(local_id)])
persistent_alias_import = run(IMPORT, alias_artifact, persistent_alias_db)
assert persistent_alias_import.returncode == 0 and \
"zones_updated=1" in persistent_alias_import.stdout, (
persistent_alias_import.stdout + persistent_alias_import.stderr
)
assert mapping(persistent_alias_db) == (
[('back', 'primary'), ('arms', 'secondary')], "back|arms"
)
assert_alias_identity(persistent_alias_db, remote_id, local_id)
persistent_replay = run(IMPORT, alias_artifact, persistent_alias_db)
assert persistent_replay.returncode == 0 and \
"zones_skipped=1" in persistent_replay.stdout, persistent_replay.stdout
assert_alias_identity(persistent_alias_db, remote_id, local_id)
# Full canonical groups must agree exactly. In particular, secondary
# claims are never unioned because that would invent direct relations.
conflict_cases = [
("complementary-secondary", entry(remote_id, "back", ["arms"]),
entry(local_id, "back", ["shoulders"])),
("primary-conflict", entry(remote_id, "back", []),
entry(local_id, "chest", [])),
("role-conflict", entry(remote_id, "back", ["arms"]),
entry(local_id, "arms", ["back"])),
("classified-unclassified", entry(remote_id, "back", []),
entry(local_id, None, [])),
]
for label, source_entry, canonical_entry in conflict_cases:
conflict_db = root / f"{label}.db"
database(conflict_db, local_id)
add_alias(conflict_db, remote_id, local_id)
before = mapping(conflict_db)
write_companion(alias_artifact, [source_entry, canonical_entry])
result = run(IMPORT, alias_artifact, conflict_db)
assert result.returncode == 1 and \
"incompatibles après résolution d'alias" in result.stdout, result.stdout
assert mapping(conflict_db) == before
assert_alias_identity(conflict_db, remote_id, local_id)
# An unresolved source cannot borrow the known canonical entry as
# identity proof, and the earlier canonical claim remains unmodified.
unknown_db = root / "unknown-group.db"
database(unknown_db, local_id)
before = mapping(unknown_db)
write_companion(alias_artifact, [
entry(local_id, "back", ["shoulders"]), entry(remote_id),
])
unknown = run(IMPORT, alias_artifact, unknown_db)
assert unknown.returncode == 1 and "exercice inconnu" in unknown.stdout
assert mapping(unknown_db) == before
# A custom exercise starts with no baseline on its creator. Publishing
# the exact snapshot acknowledges it locally; a later peer-only edit
# must then flow back instead of becoming a false simultaneous conflict.

View file

@ -7,6 +7,8 @@ import tempfile
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
SOURCE_ID = "ex_43c7375f-934c-4650-930c-45807d2f2929"
CANONICAL_ID = "ex_2d488c08-194c-4051-a3c9-34471646c1d3"
def make_db(path, equipment):
@ -46,6 +48,53 @@ def main():
assert exercise_conflict.returncode != 0 and 'conflit exercice association' in exercise_conflict.stdout
assert sqlite3.connect(target).execute('SELECT equipment_id FROM session_exercises').fetchone()[0] == 'leg_press'
# A durable flattened alias is identity evidence before raw-ID
# comparison. The retired source must corroborate the canonical
# occurrence without requiring an optional V2 proof snapshot.
alias_target = directory / 'alias-target.db'
make_db(alias_target, 'leg_press')
alias_db = sqlite3.connect(alias_target)
alias_db.execute("UPDATE exercises SET exercise_id=?", (CANONICAL_ID,))
alias_db.execute(
"CREATE TABLE exercise_aliases(source_exercise_id TEXT PRIMARY KEY, "
"canonical_exercise_id TEXT NOT NULL REFERENCES exercises(exercise_id))"
)
alias_db.execute("INSERT INTO exercise_aliases VALUES(?,?)", (SOURCE_ID, CANONICAL_ID))
alias_db.execute("PRAGMA user_version=12")
alias_db.commit(); alias_db.close()
payload['associations'][0] = {
'session_id': 'se_fixture', 'entry_id': 'sxe_fixture',
'exercise_id': SOURCE_ID, 'state': 'set', 'equipment_id': 'leg_press',
}
artifact.write_text(json.dumps(payload))
alias_replay = subprocess.run(
[sys.executable, ROOT / 'tools/import_equipment_associations.py', artifact,
'--database', alias_target],
text=True, capture_output=True,
)
assert alias_replay.returncode == 0, alias_replay.stdout + alias_replay.stderr
with sqlite3.connect(alias_target) as alias_db:
assert alias_db.execute(
'SELECT exercise_id FROM exercises'
).fetchall() == [(CANONICAL_ID,)]
assert alias_db.execute(
'SELECT source_exercise_id,canonical_exercise_id FROM exercise_aliases'
).fetchall() == [(SOURCE_ID, CANONICAL_ID)]
# A different live canonical exercise remains a genuine conflict even
# when some unrelated alias is present.
with sqlite3.connect(alias_target) as alias_db:
alias_db.execute("INSERT INTO exercises VALUES(2,'ex_other')")
payload['associations'][0]['exercise_id'] = 'ex_other'
artifact.write_text(json.dumps(payload))
canonical_conflict = subprocess.run(
[sys.executable, ROOT / 'tools/import_equipment_associations.py', artifact,
'--database', alias_target],
text=True, capture_output=True,
)
assert canonical_conflict.returncode != 0
assert 'conflit exercice association' in canonical_conflict.stdout
# The importer must validate the entire artifact before applying its
# first otherwise-valid association. v2 cannot transport custom IDs.
prevalidation_artifact = directory / 'prevalidation-rejected.json'

View file

@ -0,0 +1,121 @@
#!/usr/bin/env python3
"""Strict JSON validation regressions for exercise-alias imports."""
import importlib.util
import json
import sqlite3
import subprocess
import tempfile
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
SPEC = importlib.util.spec_from_file_location(
"import_exercise_aliases", ROOT / "tools/import_exercise_aliases.py")
MODULE = importlib.util.module_from_spec(SPEC)
SPEC.loader.exec_module(MODULE)
def test_boolean_version_rejected():
with tempfile.TemporaryDirectory() as directory:
artifact = Path(directory) / "aliases.json"
artifact.write_text(json.dumps({
"format": "trainlog-exercise-aliases",
"version": True,
"aliases": [],
}), encoding="utf-8")
try:
MODULE.load(artifact)
except ValueError as error:
assert str(error) == "artifact alias v1 invalide"
return
raise AssertionError("boolean alias artifact version was accepted")
def test_merge_preserves_same_session_child_graph():
source = "ex_11111111-1111-4111-8111-111111111111"
target = "ex_22222222-2222-4222-8222-222222222222"
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
database = root / "trainlog.db"
artifact = root / "aliases.json"
artifact.write_text(json.dumps({
"format": "trainlog-exercise-aliases", "version": 1,
"aliases": [{"source_exercise_id": source,
"canonical_exercise_id": target}],
}), encoding="utf-8")
connection = sqlite3.connect(database)
connection.executescript(f"""
PRAGMA foreign_keys=ON;
CREATE TABLE exercises(id INTEGER PRIMARY KEY,exercise_id TEXT UNIQUE,
tracking_mode TEXT,recording_mode TEXT,data_fields INTEGER);
CREATE TABLE sessions(id INTEGER PRIMARY KEY,session_id TEXT UNIQUE);
CREATE TABLE session_exercises(id INTEGER PRIMARY KEY,
session_row_id INTEGER REFERENCES sessions(id) ON DELETE CASCADE,
exercise_row_id INTEGER REFERENCES exercises(id),entry_id TEXT UNIQUE,
equipment_id TEXT);
CREATE TABLE performed_sets(id INTEGER PRIMARY KEY,
session_exercise_row_id INTEGER REFERENCES session_exercises(id) ON DELETE CASCADE,
duration_seconds INTEGER,weight_kg REAL);
CREATE TABLE continuous_activity(session_exercise_row_id INTEGER PRIMARY KEY
REFERENCES session_exercises(id) ON DELETE CASCADE,duration_seconds INTEGER);
CREATE TABLE max_results(session_exercise_row_id INTEGER PRIMARY KEY
REFERENCES session_exercises(id) ON DELETE CASCADE,max_weight_kg REAL);
CREATE TABLE exercise_body_zones(exercise_row_id INTEGER REFERENCES exercises(id)
ON DELETE CASCADE,zone_id TEXT,role TEXT,PRIMARY KEY(exercise_row_id,zone_id));
CREATE TABLE exercise_body_zone_sync(exercise_row_id INTEGER PRIMARY KEY
REFERENCES exercises(id) ON DELETE CASCADE,synced_state TEXT);
CREATE TABLE exercise_aliases(source_exercise_id TEXT PRIMARY KEY,
canonical_exercise_id TEXT REFERENCES exercises(exercise_id) ON DELETE RESTRICT);
INSERT INTO exercises VALUES(1,'{source}','duration','sets',0);
INSERT INTO exercises VALUES(2,'{target}','duration','sets',0);
INSERT INTO sessions VALUES(1,'se_same_session');
INSERT INTO session_exercises VALUES(10,1,1,'sxe_source_set','eq_cable');
INSERT INTO session_exercises VALUES(11,1,1,'sxe_source_continuous','eq_treadmill');
INSERT INTO session_exercises VALUES(12,1,1,'sxe_source_max','eq_cable');
INSERT INTO session_exercises VALUES(13,1,2,'sxe_target','eq_target');
INSERT INTO performed_sets VALUES(20,10,45,37.5);
INSERT INTO continuous_activity VALUES(11,900);
INSERT INTO max_results VALUES(12,82.5);
INSERT INTO performed_sets VALUES(21,13,30,20.0);
INSERT INTO exercise_body_zones VALUES(1,'arms','primary');
INSERT INTO exercise_body_zones VALUES(2,'arms','primary');
INSERT INTO exercise_body_zone_sync VALUES(1,'arms|');
INSERT INTO exercise_body_zone_sync VALUES(2,'arms|');
PRAGMA user_version=12;
""")
connection.commit()
connection.close()
result = subprocess.run(
["python3", str(ROOT / "tools/import_exercise_aliases.py"),
str(artifact), "--database", str(database)],
cwd=ROOT, text=True, capture_output=True,
)
assert result.returncode == 0, result.stdout + result.stderr
connection = sqlite3.connect(database)
try:
assert connection.execute(
"SELECT source_exercise_id,canonical_exercise_id FROM exercise_aliases"
).fetchall() == [(source, target)]
assert connection.execute(
"SELECT id,exercise_row_id,entry_id,equipment_id FROM session_exercises ORDER BY id"
).fetchall() == [
(10, 2, "sxe_source_set", "eq_cable"),
(11, 2, "sxe_source_continuous", "eq_treadmill"),
(12, 2, "sxe_source_max", "eq_cable"),
(13, 2, "sxe_target", "eq_target"),
]
assert connection.execute("SELECT * FROM performed_sets ORDER BY id").fetchall() == [
(20, 10, 45, 37.5), (21, 13, 30, 20.0),
]
assert connection.execute("SELECT * FROM continuous_activity").fetchall() == [(11, 900)]
assert connection.execute("SELECT * FROM max_results").fetchall() == [(12, 82.5)]
assert connection.execute("PRAGMA foreign_key_check").fetchall() == []
finally:
connection.close()
if __name__ == "__main__":
test_boolean_version_rejected()
test_merge_preserves_same_session_child_graph()

View file

@ -27,8 +27,8 @@ def main():
args = parser.parse_args()
connection = sqlite3.connect(args.database)
try:
if connection.execute("PRAGMA user_version;").fetchone()[0] not in (8, 9, 10, 11):
raise ValueError("schema desktop v8 à v11 requis")
if connection.execute("PRAGMA user_version;").fetchone()[0] not in (8, 9, 10, 11, 12):
raise ValueError("schema desktop v8 à v12 requis")
known_equipment = load_supplied_equipment_ids(args.catalog)
known_equipment.update(row[0] for row in connection.execute(
"SELECT equipment_id FROM custom_equipment"))

View file

@ -23,8 +23,8 @@ def main():
connection = sqlite3.connect(args.database)
connection.row_factory = sqlite3.Row
try:
if connection.execute("PRAGMA user_version").fetchone()[0] not in (8, 9, 10, 11):
raise ValueError("schema desktop v8 à v11 requis")
if connection.execute("PRAGMA user_version").fetchone()[0] not in (8, 9, 10, 11, 12):
raise ValueError("schema desktop v8 à v12 requis")
equipment = [dict(row) for row in connection.execute(
"SELECT equipment_id,display_name,label_name,equipment_type,load_semantics "
"FROM custom_equipment ORDER BY equipment_id")]

View file

@ -0,0 +1,38 @@
#!/usr/bin/env python3
"""Export the bounded EXERCISE_MERGE_V1 identity companion."""
import argparse
import json
import sqlite3
from pathlib import Path
MAX_ALIASES = 4096
def main():
parser = argparse.ArgumentParser()
parser.add_argument("output", type=Path)
parser.add_argument("--database", type=Path, required=True)
args = parser.parse_args()
con = sqlite3.connect(args.database)
try:
if con.execute("PRAGMA user_version").fetchone()[0] != 12:
raise ValueError("schema desktop v12 requis")
rows = con.execute(
"SELECT source_exercise_id,canonical_exercise_id FROM exercise_aliases "
"ORDER BY source_exercise_id COLLATE BINARY"
).fetchall()
if len(rows) > MAX_ALIASES:
raise ValueError("trop d'alias exercice")
payload = {"format": "trainlog-exercise-aliases", "version": 1,
"aliases": [{"source_exercise_id": row[0],
"canonical_exercise_id": row[1]} for row in rows]}
args.output.parent.mkdir(parents=True, exist_ok=True)
args.output.write_text(json.dumps(payload, ensure_ascii=False,
separators=(",", ":")), encoding="utf-8")
finally:
con.close()
print(f"EXERCISE_ALIAS_EXPORT=PASS aliases={len(rows)}")
if __name__ == "__main__":
main()

View file

@ -40,8 +40,8 @@ def main():
connection = sqlite3.connect(args.database)
connection.row_factory = sqlite3.Row
try:
if connection.execute("PRAGMA user_version").fetchone()[0] != 11:
raise ValueError("schema desktop v11 requis")
if connection.execute("PRAGMA user_version").fetchone()[0] not in (11, 12):
raise ValueError("schema desktop v11/v12 requis")
exercises = []
for exercise in connection.execute("SELECT id,exercise_id FROM exercises ORDER BY exercise_id"):
if EXERCISE_ID_PATTERN.fullmatch(exercise["exercise_id"]) is None:

View file

@ -64,7 +64,7 @@ def main():
# CONTRACT: v8 adds only desktop-local custom equipment. The PC
# catalogue artifact is unchanged, but it must read the current
# canonical desktop schema rather than accept a stale pre-v8 database.
if version not in (8, 9, 10, 11):
if version not in (8, 9, 10, 11, 12):
raise SystemExit(
"PC_CATALOG_EXPORT=FAIL "
f"schema={version}"

View file

@ -67,8 +67,8 @@ def main():
con.row_factory = sqlite3.Row
try:
schema_version = con.execute("PRAGMA user_version").fetchone()[0]
if schema_version != 11 and not (args.version == 2 and schema_version == 10):
raise ValueError("schema desktop v11 requis (v10 accepté pour export V2 explicite)")
if schema_version not in (11, 12) and not (args.version == 2 and schema_version == 10):
raise ValueError("schema desktop v11/v12 requis (v10 accepté pour export V2 explicite)")
known_equipment = supplied_equipment_ids()
known_equipment.update(row[0] for row in con.execute(
"SELECT equipment_id FROM custom_equipment"))

View file

@ -91,6 +91,28 @@ def load_mobile_occurrences(path):
return occurrences
def canonical_exercise_id(connection, exercise_id):
"""Resolve one flattened durable alias without inventing identity."""
aliases_available = connection.execute(
"SELECT 1 FROM sqlite_master WHERE type='table' AND name='exercise_aliases';"
).fetchone() is not None
if not aliases_available:
return exercise_id
alias = connection.execute(
"SELECT canonical_exercise_id FROM exercise_aliases "
"WHERE source_exercise_id=?;",
(exercise_id,),
).fetchone()
if alias is None:
return exercise_id
target_exists = connection.execute(
"SELECT 1 FROM exercises WHERE exercise_id=?;", (alias[0],)
).fetchone() is not None
if not target_exists:
fail(f"alias exercice sans cible canonique: {exercise_id}")
return alias[0]
def main():
parser = argparse.ArgumentParser()
parser.add_argument("artifact", type=Path)
@ -113,8 +135,8 @@ def main():
fail("clés extension équipement invalides")
connection = sqlite3.connect(args.database)
try:
if connection.execute("PRAGMA user_version;").fetchone()[0] not in (8, 9, 10, 11):
fail("schema desktop v8 à v11 requis")
if connection.execute("PRAGMA user_version;").fetchone()[0] not in (8, 9, 10, 11, 12):
fail("schema desktop v8 à v12 requis")
known = load_catalog(args.catalog)
known.update(row[0] for row in connection.execute(
"SELECT equipment_id FROM custom_equipment"))
@ -137,7 +159,12 @@ def main():
(session_id, entry_id)).fetchone()
if exists is None:
fail(f"entrée séance inconnue: {session_id}/{entry_id}")
if exists[0] != exercise_id:
# WHY: the association companion can outlive the creator ID used
# by its source occurrence. The durable flattened alias is the
# synchronization identity evidence and is stronger than raw text.
stored_canonical = canonical_exercise_id(connection, exists[0])
incoming_canonical = canonical_exercise_id(connection, exercise_id)
if stored_canonical != incoming_canonical:
proof = mobile_occurrences.get((session_id, entry_id))
incoming_still_exists = connection.execute(
"SELECT 1 FROM exercises WHERE exercise_id=?;",
@ -145,10 +172,13 @@ def main():
).fetchone() is not None
if proof != exercise_id or incoming_still_exists:
fail(f"conflit exercice association: {session_id}/{entry_id}")
# WHY: import_mobile_export may have replaced a safe duplicate
# creator ID with the canonical desktop ID. entry_id is the V2
# occurrence identity; the just-validated source snapshot proves
# that this stale exercise_id belongs to that same occurrence.
# CONTRACT: this fallback is only for schemas/runs without a
# persistent alias. import_mobile_export may have removed a
# profile-compatible duplicate; the selected V2 snapshot then
# proves the stale source identity for this stable occurrence.
# INVARIANT: identity reconciliation never changes session_id,
# entry_id, equipment_id, or any persisted occurrence. A distinct
# live canonical exercise therefore remains a hard conflict.
if exists[1] != equipment_id:
fail(f"conflit association équipement: {session_id}/{entry_id}")
print("EQUIPMENT_ASSOCIATIONS_IMPORT=PASS")

View file

@ -60,8 +60,8 @@ def main():
definitions = validate(json.loads(args.artifact.read_text(encoding="utf-8")), supplied_ids(args.catalog))
connection = sqlite3.connect(args.database)
try:
if connection.execute("PRAGMA user_version").fetchone()[0] not in (8, 9, 10, 11):
fail("schema desktop v8 à v11 requis")
if connection.execute("PRAGMA user_version").fetchone()[0] not in (8, 9, 10, 11, 12):
fail("schema desktop v8 à v12 requis")
imported = skipped = 0
# Validate every same-ID row before inserting any definition.
for definition in definitions:

View file

@ -0,0 +1,103 @@
#!/usr/bin/env python3
"""Validate and import the bounded EXERCISE_MERGE_V1 identity companion."""
import argparse
import json
import re
import sqlite3
from pathlib import Path
MAX_BYTES = 1024 * 1024
MAX_ALIASES = 4096
EXERCISE_ID = re.compile(r"^ex_[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$")
def fail(message):
raise ValueError(message)
def load(path):
if path.stat().st_size > MAX_BYTES:
fail("artifact alias trop volumineux")
def unique(pairs):
value = {}
for key, item in pairs:
if key in value:
fail(f"champ JSON dupliqué: {key}")
value[key] = item
return value
value = json.loads(path.read_text(encoding="utf-8"), object_pairs_hook=unique)
if set(value) != {"format", "version", "aliases"} or \
value["format"] != "trainlog-exercise-aliases" or \
type(value["version"]) is not int or value["version"] != 1:
fail("artifact alias v1 invalide")
aliases = value["aliases"]
if not isinstance(aliases, list) or len(aliases) > MAX_ALIASES:
fail("tableau aliases invalide ou hors borne")
result, seen = [], set()
for index, item in enumerate(aliases):
if not isinstance(item, dict) or set(item) != {"source_exercise_id", "canonical_exercise_id"}:
fail(f"aliases[{index}] invalide")
source, canonical = item["source_exercise_id"], item["canonical_exercise_id"]
if not isinstance(source, str) or not EXERCISE_ID.fullmatch(source) or \
not isinstance(canonical, str) or not EXERCISE_ID.fullmatch(canonical):
fail(f"aliases[{index}]: identité exercice invalide")
if source == canonical or source in seen:
fail(f"aliases[{index}]: cycle ou source dupliquée")
seen.add(source); result.append((source, canonical))
# CONTRACT: deterministic order is part of the companion representation.
if result != sorted(result):
fail("aliases non triés")
sources = {source for source, _ in result}
if any(canonical in sources for _, canonical in result):
fail("chaîne/cycle alias interdite; mappings aplatis requis")
return result
def main():
parser = argparse.ArgumentParser()
parser.add_argument("artifact", type=Path)
parser.add_argument("--database", type=Path, required=True)
args = parser.parse_args()
aliases = load(args.artifact)
con = sqlite3.connect(args.database)
try:
con.execute("PRAGMA foreign_keys=ON")
if con.execute("PRAGMA user_version").fetchone()[0] != 12:
fail("schema desktop v12 requis")
con.execute("BEGIN IMMEDIATE")
for source, canonical in aliases:
target = con.execute("SELECT id,tracking_mode,recording_mode,data_fields FROM exercises WHERE exercise_id=?", (canonical,)).fetchone()
if target is None:
fail(f"cible canonique absente: {canonical}")
current = con.execute("SELECT canonical_exercise_id FROM exercise_aliases WHERE source_exercise_id=?", (source,)).fetchone()
if current is not None:
if current[0] != canonical: fail(f"conflit alias: {source}")
continue
retired = con.execute("SELECT id,tracking_mode,recording_mode,data_fields FROM exercises WHERE exercise_id=?", (source,)).fetchone()
if retired is not None:
if retired[1:] != target[1:]: fail(f"profil incompatible: {source}")
primary = lambda row_id: con.execute("SELECT zone_id FROM exercise_body_zones WHERE exercise_row_id=? AND role='primary'", (row_id,)).fetchone()
sp, tp = primary(retired[0]), primary(target[0])
if sp and tp and sp[0] != tp[0]: fail(f"zone primaire incompatible: {source}")
chosen = tp[0] if tp else (sp[0] if sp else None)
if chosen:
con.execute("DELETE FROM exercise_body_zones WHERE exercise_row_id=? AND zone_id=?", (target[0], chosen))
con.execute("INSERT INTO exercise_body_zones VALUES(?,?,'primary')", (target[0], chosen))
con.execute("INSERT OR IGNORE INTO exercise_body_zones SELECT ?,zone_id,'secondary' FROM exercise_body_zones WHERE exercise_row_id=? AND role='secondary' AND zone_id<>COALESCE(?, '')", (target[0], retired[0], chosen))
con.execute("UPDATE session_exercises SET exercise_row_id=? WHERE exercise_row_id=?", (target[0], retired[0]))
con.execute("DELETE FROM exercise_body_zone_sync WHERE exercise_row_id IN(?,?)", (retired[0], target[0]))
# INVARIANT: earlier sources stay one hop from a live target;
# deleting an intermediate canonical can never create a chain.
con.execute("UPDATE exercise_aliases SET canonical_exercise_id=? WHERE canonical_exercise_id=?", (canonical, source))
con.execute("DELETE FROM exercises WHERE id=?", (retired[0],))
con.execute("INSERT INTO exercise_aliases VALUES(?,?)", (source, canonical))
con.commit()
except Exception:
con.rollback(); raise
finally:
con.close()
print(f"EXERCISE_ALIAS_IMPORT=PASS aliases={len(aliases)}")
if __name__ == "__main__":
main()

View file

@ -107,6 +107,18 @@ def resolve_exercise_row(connection, exercise_id, mobile_proof):
).fetchone()
if row is not None:
return row[0]
aliases_available = connection.execute(
"SELECT 1 FROM sqlite_master WHERE type='table' AND name='exercise_aliases'",
).fetchone() is not None
if aliases_available:
row = connection.execute(
"SELECT e.id FROM exercise_aliases a "
"JOIN exercises e ON e.exercise_id=a.canonical_exercise_id "
"WHERE a.source_exercise_id=?",
(exercise_id,),
).fetchone()
if row is not None:
return row[0]
proof = mobile_proof.get(exercise_id)
if proof is None:
raise ImportFailure(f"exercice inconnu: {exercise_id}")
@ -183,26 +195,41 @@ def main():
seen.add(exercise_id)
parsed.append((exercise_id, primary, sorted(secondary)))
proof_path = args.mobile_export
if proof_path is None:
candidate = args.input.with_name("trainlog-mobile-export-v2.json")
if candidate.exists():
proof_path = candidate
mobile_proof = load_mobile_exercise_proof(proof_path) if proof_path is not None else {}
# A V2 snapshot is reconciliation evidence only when the caller selected
# and supplied that exact snapshot. Never discover a sibling implicitly:
# a stale V2 file must not prove IDs for a separately selected V3/V1 run.
mobile_proof = (load_mobile_exercise_proof(args.mobile_export)
if args.mobile_export is not None else {})
connection = sqlite3.connect(args.database)
updated = skipped = kept_local = 0
resolved_rows = set()
try:
if connection.execute("PRAGMA user_version").fetchone()[0] != 11:
raise ImportFailure("schema desktop v11 requis")
if connection.execute("PRAGMA user_version").fetchone()[0] not in (11, 12):
raise ImportFailure("schema desktop v11/v12 requis")
connection.execute("PRAGMA foreign_keys=ON")
connection.execute("BEGIN IMMEDIATE")
grouped = {}
for exercise_id, primary, secondary in parsed:
row_id = resolve_exercise_row(connection, exercise_id, mobile_proof)
if row_id in resolved_rows:
raise ImportFailure(f"deux identités entrantes résolvent le même exercice: {exercise_id}")
resolved_rows.add(row_id)
incoming = (primary, secondary)
group = grouped.get(row_id)
if group is None:
grouped[row_id] = (exercise_id, incoming)
elif group[1] != incoming:
# CONTRACT: secondary zones are direct ordered-set metadata, so
# alias coalescing may accept equality but must never union two
# companion claims or choose between primary/secondary roles.
raise ImportFailure(
f"données de zones incompatibles après résolution d'alias: "
f"{group[0]} / {exercise_id}"
)
# INVARIANT: resolve and compare the complete alias groups before the
# first write. This makes one reconciliation decision per canonical
# exercise and prevents input order from changing the chosen payload.
planned = []
for row_id, (exercise_id, incoming) in grouped.items():
primary, secondary = incoming
local_primary, local_secondary = current(connection, row_id)
local_state = state(local_primary, local_secondary)
incoming_state = state(primary, secondary)
@ -211,20 +238,32 @@ def main():
).fetchone()
baseline = None if baseline_row is None else baseline_row[0]
if local_state == incoming_state:
connection.execute("INSERT OR REPLACE INTO exercise_body_zone_sync VALUES(?,?)", (row_id, incoming_state))
skipped += 1
planned.append(("skip", row_id, primary, secondary, incoming_state))
elif baseline is not None and local_state == baseline:
replace(connection, row_id, primary, secondary)
connection.execute("INSERT OR REPLACE INTO exercise_body_zone_sync VALUES(?,?)", (row_id, incoming_state))
updated += 1
planned.append(("update", row_id, primary, secondary, incoming_state))
elif baseline is not None and incoming_state == baseline:
kept_local += 1
planned.append(("keep", row_id, primary, secondary, incoming_state))
elif baseline is None and local_state == "|":
replace(connection, row_id, primary, secondary)
connection.execute("INSERT OR REPLACE INTO exercise_body_zone_sync VALUES(?,?)", (row_id, incoming_state))
updated += 1
planned.append(("update", row_id, primary, secondary, incoming_state))
else:
raise ImportFailure(f"conflit zones simultané: {exercise_id}")
# WHY: aliases are compatibility identities, not additional exercises.
# Applying the plan once per resolved row avoids duplicate mutations and
# cannot recreate a retired source ID.
for action, row_id, primary, secondary, incoming_state in planned:
if action == "keep":
kept_local += 1
continue
if action == "update":
replace(connection, row_id, primary, secondary)
updated += 1
else:
skipped += 1
connection.execute(
"INSERT OR REPLACE INTO exercise_body_zone_sync VALUES(?,?)",
(row_id, incoming_state),
)
connection.commit()
print("EXERCISE_BODY_ZONES_IMPORT=PASS")
print(f"zones_updated={updated}")

View file

@ -819,9 +819,9 @@ def require_supported_schema(connection):
# CONTRACT: v9 owns explicit max_results; earlier supported schemas remain
# readable for legacy artifacts and are never made to fake that table.
if version not in (5, 6, 7, 8, 9, 10, 11):
if version not in (5, 6, 7, 8, 9, 10, 11, 12):
raise ImportFailure(
f"base desktop schema v5 à v11 attendue, version trouvée: {version}"
f"base desktop schema v5 à v12 attendue, version trouvée: {version}"
)
@ -1017,6 +1017,29 @@ def import_exercises(
exercise_id,
)
# CONTRACT: a retired creator ID is identity input only. It resolves
# before normalized-name reconciliation and cannot rewrite canonical
# catalogue presentation metadata unless that canonical ID itself was
# present in the incoming catalogue.
if by_id is None and connection.execute(
"PRAGMA user_version;"
).fetchone()[0] >= 12:
alias = connection.execute(
"SELECT canonical_exercise_id FROM exercise_aliases "
"WHERE source_exercise_id=?;", (exercise_id,),
).fetchone()
if alias is not None:
canonical = lookup_exercise_by_id(connection, alias[0])
if canonical is None:
raise ImportFailure("alias exercice sans cible canonique")
if not profiles_are_reconcilable(canonical, exercise):
raise ImportFailure(profile_conflict(canonical, exercise))
mapping[exercise_id] = canonical["exercise_id"]
report["exercises_skipped"] += 1
trace_exercise_decision(trace_exercises, exercise,
f"alias:{canonical['exercise_id']}", "existing-identical")
continue
if by_id is not None:
if not profiles_are_reconcilable(
by_id,

View file

@ -0,0 +1,278 @@
#!/usr/bin/env python3
"""Exercise APP_SHELL_V1 through a real Notcurses process in an isolated tmux PTY.
This is deliberately a black-box companion to the C geometry tests. Capture-pane
is text, so it proves routes, overlays, footer reservation and interaction; it does
not infer terminal cell widths from Python UTF-8 string lengths.
"""
import argparse
import hashlib
import json
import os
import re
import subprocess
import sys
import time
import uuid
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
SIZES = ((120, 35), (120, 31), (100, 30), (100, 25), (80, 24), (72, 20))
class Failure(RuntimeError):
pass
class PtyValidation:
def __init__(self, binary, output):
self.binary = binary.resolve()
self.output_root = output.resolve()
self.output = self.output_root / ("run-" + uuid.uuid4().hex)
self.data_home = self.output / "xdg-data"
self.socket = f"trainlog-app-shell-{os.getpid()}-{uuid.uuid4().hex[:8]}"
self.session = "app-shell"
self.checks = []
self.captures = {}
self.height = 35
def tmux(self, *args, timeout=5, check=True):
command = ["tmux", "-L", self.socket, *args]
result = subprocess.run(command, text=True, stdout=subprocess.PIPE,
stderr=subprocess.PIPE, timeout=timeout)
if check and result.returncode:
raise Failure("tmux failed: %s\nstderr: %s" % (command, result.stderr.strip()))
return result
def send(self, *keys):
self.tmux("send-keys", "-t", self.session, *keys)
time.sleep(0.35)
def literal(self, value):
self.tmux("send-keys", "-t", self.session, "-l", value)
time.sleep(0.35)
def capture(self, name):
text = self.tmux("capture-pane", "-p", "-t", self.session).stdout
path = self.output / f"pty-{name}.txt"
path.write_text(text, encoding="utf-8")
self.captures[name] = path.name
return text
def require(self, condition, name, detail=""):
if not condition:
raise Failure("assertion failed: %s%s" % (name, ": " + detail if detail else ""))
self.checks.append(name)
def footer(self, text):
rows = text.splitlines()
return "\n".join(rows[-2:])
def check_shell(self, name, text, marker):
rows = text.splitlines()
self.require(len(rows) == self.height, name + ": terminal height")
self.require("TRAINLOG" in rows[0], name + ": header")
# CONTRACT: section labels in the persistent sidebar are not evidence
# that a destination opened. Only the reserved route header identifies it.
route = "Accueil" if marker in ("Votre entraînement", "Accueil") else marker
self.require(rows[1].strip().startswith(route), name + ": active route", route)
self.require(marker in text, name + ": route marker", marker)
self.require("F7 Actions" in self.footer(text), name + ": reserved two-line footer")
def resize(self, width, height):
self.tmux("resize-window", "-t", self.session, "-x", str(width), "-y", str(height))
self.height = height
time.sleep(0.45)
def pane_dead(self):
return self.tmux("display-message", "-p", "-t", self.session,
"#{pane_dead}").stdout.strip()
def data_snapshot(self):
"""Hash initialized data after startup; navigation must not mutate it."""
snapshot = {}
for path in self.data_home.rglob("*"):
if path.is_file():
snapshot[str(path.relative_to(self.data_home))] = hashlib.sha256(
path.read_bytes()).hexdigest()
return snapshot
@staticmethod
def selected_list_line(text):
"""Return the content selection marker, excluding the sidebar marker."""
for line in text.splitlines():
# Content may share a physical row with a sidebar label. Its marker
# follows the content padding; the sidebar marker starts in column 1.
selected = re.search(r" {2,}(>\s+\S.*)", line)
if selected:
return selected.group(1).strip()
return ""
def run(self):
self.output.mkdir(parents=True, exist_ok=False)
self.data_home.mkdir(parents=True, exist_ok=True)
if not self.binary.is_file() or not os.access(self.binary, os.X_OK):
raise Failure("binary is not executable: %s" % self.binary)
# A distinct server/socket and XDG directory make this run unable to touch
# a user's tmux server or normal Trainlog database. Do not set HOME.
environment = ["env", "XDG_DATA_HOME=" + str(self.data_home),
"TERM=xterm-256color", str(self.binary)]
self.tmux("new-session", "-d", "-s", self.session, "-x", "120", "-y", "35", *environment,
timeout=8)
self.tmux("set-option", "-t", self.session, "remain-on-exit", "on")
# Notcurses may wait for DA. Send it as terminal input, then normalize any
# resulting '?' help route with a physical Escape and explicit Home route.
self.literal("\x1b[?1;2c")
self.send("Escape")
self.send("0")
time.sleep(0.6)
home = self.capture("home")
self.check_shell("home", home, "Votre entraînement")
database_before_navigation = self.data_snapshot()
# All six contract dimensions must retain a usable shell and footer.
for width, height in SIZES:
self.resize(width, height)
view = self.capture(f"size-{width}x{height}")
self.check_shell(f"geometry-{width}x{height}", view,
"Votre entraînement" if width >= 80 else "Accueil")
self.resize(120, 35)
self.send("?")
help_view = self.capture("help")
self.require("Aide contextuelle" in help_view, "question opens help")
self.send("Escape")
self.check_shell("escape closes help", self.capture("after-help-escape"), "Votre entraînement")
# F6 is a sidebar focus at wide sizes, and an overlay at compact sizes.
self.send("F6", "Down", "Down", "Down", "Enter")
equipment = self.capture("equipment-from-f6")
self.check_shell("F6 navigation opens equipment", equipment, "Équipements")
self.send("F6", "Escape")
restored = self.capture("equipment-after-f6-escape")
self.check_shell("F6 Escape restores equipment", restored, "Équipements")
self.send("Tab", "BTab")
self.check_shell("Tab and Shift-Tab preserve route", self.capture("equipment-after-tabs"), "Équipements")
# A sidebar focus survives compact resize as the visible Navigation
# control. Its overlay restores both focus and section selection.
self.send("F6", "Down", "Down", "Down")
self.resize(100, 25)
compact_navigation = self.capture("compact-navigation-focus")
self.require("> F6 Navigation" in compact_navigation,
"sidebar focus becomes visible compact Navigation control")
self.send("Enter")
self.require("Navigation" in self.capture("compact-navigation-overlay"),
"compact Navigation Enter opens overlay")
self.send("Escape")
restored_navigation = self.capture("compact-navigation-restored")
self.require("> F6 Navigation" in restored_navigation,
"Navigation Escape restores compact control focus")
self.send("BTab")
self.require("> F7 Actions" in self.capture("compact-reverse-actions"),
"reverse Tab reaches rendered Actions control")
self.send("BTab", "BTab", "BTab")
self.require("> F6 Navigation" in self.capture("compact-reverse-navigation"),
"reverse Tab follows actions, content, search, navigation")
self.send("Tab", "Tab")
self.resize(120, 35)
self.send("Down")
before_detail = self.capture("equipment-selected")
selected_before_detail = self.selected_list_line(before_detail)
self.require(bool(selected_before_detail), "equipment selection visible before detail")
self.send("Enter")
detail = self.capture("equipment-detail")
self.require("Équipements / Fiche" in detail, "equipment detail opens")
self.send("Escape")
back_to_list = self.capture("equipment-detail-back")
self.check_shell("detail Escape restores equipment list", back_to_list, "Équipements")
self.require("Équipements / Fiche" not in back_to_list,
"detail Escape restores list route")
self.require(self.selected_list_line(back_to_list) == selected_before_detail,
"detail Escape restores equipment selection")
self.send("F7")
actions = self.capture("actions")
self.require("Actions" in actions, "F7 opens actions")
self.send("Escape")
self.check_shell("F7 Escape restores equipment", self.capture("after-actions-escape"), "Équipements")
# The minimum terminal still supports list search and overlay isolation.
self.resize(72, 20)
self.send("4")
minimum_equipment = self.capture("minimum-equipment")
self.check_shell("72x20 equipment", minimum_equipment, "Équipements")
self.send("/", "l", "e", "g")
filtered = self.capture("equipment-filtered")
self.require("leg" in filtered.lower(), "live equipment filter")
self.send("Escape")
cleared = self.capture("equipment-search-cleared")
self.require("Recherche : — [saisie]" in cleared,
"first Escape clears query and retains search focus")
self.send("Escape")
search_closed = self.capture("equipment-search-closed")
self.check_shell("second Escape returns equipment list", search_closed, "Équipements")
self.require("[saisie]" not in search_closed, "second Escape clears search focus")
self.send("?")
self.require("Aide contextuelle" in self.capture("minimum-help"), "help at minimum geometry")
self.send("3")
self.require("Aide contextuelle" in self.capture("help-overlay-isolates-global-route"),
"global route ignored while help overlay open")
self.resize(100, 25)
self.require("Aide contextuelle" in self.capture("help-after-resize"), "resize preserves help overlay")
self.send("Escape")
self.check_shell("Escape restores equipment after overlay resize",
self.capture("after-overlay-resize-escape"), "Équipements")
self.send("0", "q")
time.sleep(0.5)
self.require(self.pane_dead() == "1", "q exits pane")
self.require(self.tmux("display-message", "-p", "-t", self.session,
"#{pane_dead_status}").stdout.strip() == "0", "q exits zero")
self.require(self.data_snapshot() == database_before_navigation,
"navigation leaves initialized temporary database unchanged")
def close(self):
# This is our unique socket, never the caller's tmux server.
self.tmux("kill-server", check=False)
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--binary", type=Path, default=ROOT / "build/tui/trainlog")
parser.add_argument("--output", type=Path, default=Path("/tmp/trainlog-app-shell-v1"))
args = parser.parse_args()
runner = PtyValidation(args.binary, args.output)
result = {"binary": str(args.binary), "output": str(runner.output), "checks": [], "captures": {}}
try:
runner.run()
result.update(status="PASS", checks=runner.checks, captures=runner.captures)
print("APP_SHELL_PTY=PASS")
print("ARTIFACT_DIR=" + str(runner.output))
for check in runner.checks:
print("CHECK=" + check)
except (Failure, subprocess.TimeoutExpired, OSError) as error:
result.update(status="FAIL", error=str(error), checks=runner.checks, captures=runner.captures)
print("APP_SHELL_PTY=FAIL: " + str(error), file=sys.stderr)
return 1
finally:
runner.output.mkdir(parents=True, exist_ok=True)
(runner.output / "results.json").write_text(json.dumps(result, indent=2, ensure_ascii=False) + "\n",
encoding="utf-8")
(runner.output / "pty-script.md").write_text(
"# APP_SHELL_V1 PTY evidence\n\n"
"`validate_app_shell_pty.py` starts the supplied binary in a unique tmux "
"server with a temporary `XDG_DATA_HOME`. Captures are text snapshots of "
"the actual pane. They verify route and overlay behavior and the reserved "
"footer content at each requested geometry; they do not attempt to prove "
"terminal-cell width from UTF-8 byte or Python-string length. The C geometry "
"tests provide that complementary cell-bound coverage.\n",
encoding="utf-8")
runner.close()
return 0
if __name__ == "__main__":
sys.exit(main())

View file

@ -0,0 +1,304 @@
#ifndef TRAINLOG_APP_SHELL_H
#define TRAINLOG_APP_SHELL_H
/**
* @file app_shell.h
* @brief Trainlog-owned state contracts for the persistent desktop shell.
*/
#include <stdbool.h>
#include <stddef.h>
#define TRAINLOG_SHELL_MIN_COLUMNS 72
#define TRAINLOG_SHELL_MIN_ROWS 20
#define TRAINLOG_SHELL_SIDEBAR_COLUMNS 22
#define TRAINLOG_SHELL_OVERLAY_LIMIT 3U
#define TRAINLOG_SHELL_ROUTE_DEPTH 8U
#define TRAINLOG_SHELL_ACTION_LIMIT 24U
#define TRAINLOG_SHELL_SEARCH_CAPACITY 201U
#define TRAINLOG_SHELL_STABLE_ID_CAPACITY 80U
typedef struct TrainlogRect {
int y;
int x;
int height;
int width;
} TrainlogRect;
typedef struct TrainlogShellLayout {
int rows;
int columns;
bool usable;
bool sidebar_visible;
bool sidebar_expanded;
TrainlogRect header;
TrainlogRect sidebar;
TrainlogRect separator;
TrainlogRect content;
TrainlogRect footer;
} TrainlogShellLayout;
typedef enum TrainlogAppRoute {
TRAINLOG_ROUTE_HOME = 0,
TRAINLOG_ROUTE_SESSIONS,
TRAINLOG_ROUTE_SESSION_CURRENT,
TRAINLOG_ROUTE_SESSION_GENERATOR,
TRAINLOG_ROUTE_SESSION_MANUAL,
TRAINLOG_ROUTE_SESSIONS_COMPLETED,
TRAINLOG_ROUTE_SESSION_DETAIL,
TRAINLOG_ROUTE_EXERCISES,
TRAINLOG_ROUTE_EXERCISE_DETAIL,
TRAINLOG_ROUTE_EXERCISE_KNOWLEDGE,
TRAINLOG_ROUTE_EXERCISE_PERFORMANCE,
TRAINLOG_ROUTE_EXERCISE_MAX,
TRAINLOG_ROUTE_EQUIPMENT,
TRAINLOG_ROUTE_EQUIPMENT_DETAIL,
TRAINLOG_ROUTE_STATS,
TRAINLOG_ROUTE_STATS_EXERCISE,
TRAINLOG_ROUTE_BODY,
TRAINLOG_ROUTE_BODY_DETAIL,
TRAINLOG_ROUTE_BODY_METRIC,
TRAINLOG_ROUTE_BODY_TRENDS,
TRAINLOG_ROUTE_BODY_GLOBAL,
TRAINLOG_ROUTE_BODY_ANALYTICS,
TRAINLOG_ROUTE_MAX,
TRAINLOG_ROUTE_SYNC,
TRAINLOG_ROUTE_SETTINGS,
TRAINLOG_ROUTE_COUNT
} TrainlogAppRoute;
typedef enum TrainlogFocusTarget {
TRAINLOG_FOCUS_NAVIGATION = 0,
TRAINLOG_FOCUS_SEARCH,
TRAINLOG_FOCUS_CONTENT,
TRAINLOG_FOCUS_EDITOR,
TRAINLOG_FOCUS_ACTIONS,
TRAINLOG_FOCUS_OVERLAY
} TrainlogFocusTarget;
typedef enum TrainlogIntent {
TRAINLOG_INTENT_NONE = 0,
TRAINLOG_INTENT_OPEN_ROUTE,
TRAINLOG_INTENT_OPEN_NAVIGATION,
TRAINLOG_INTENT_OPEN_ACTIONS,
TRAINLOG_INTENT_OPEN_HELP,
TRAINLOG_INTENT_OPEN_SEARCH,
TRAINLOG_INTENT_PRIMARY,
TRAINLOG_INTENT_BACK,
TRAINLOG_INTENT_QUIT,
TRAINLOG_INTENT_DISCARD,
TRAINLOG_INTENT_SAVE,
TRAINLOG_INTENT_REFRESH
} TrainlogIntent;
typedef struct TrainlogRouteTarget {
TrainlogAppRoute route;
char stable_id[TRAINLOG_SHELL_STABLE_ID_CAPACITY];
} TrainlogRouteTarget;
typedef struct TrainlogNavigationState {
TrainlogRouteTarget current;
TrainlogRouteTarget back[TRAINLOG_SHELL_ROUTE_DEPTH];
size_t back_count;
} TrainlogNavigationState;
typedef struct TrainlogAction {
const char *identifier;
int key;
const char *label;
bool enabled;
unsigned priority;
TrainlogIntent intent;
TrainlogAppRoute route;
} TrainlogAction;
typedef struct TrainlogActionModel {
TrainlogAction items[TRAINLOG_SHELL_ACTION_LIMIT];
size_t count;
} TrainlogActionModel;
typedef enum TrainlogOverlayType {
TRAINLOG_OVERLAY_NAVIGATION = 0,
TRAINLOG_OVERLAY_ACTIONS,
TRAINLOG_OVERLAY_HELP,
TRAINLOG_OVERLAY_CONFIRMATION,
TRAINLOG_OVERLAY_RECOVERY,
TRAINLOG_OVERLAY_EXERCISE_MERGE
} TrainlogOverlayType;
typedef struct TrainlogOverlay {
TrainlogOverlayType type;
TrainlogFocusTarget restore_focus;
char restore_stable_id[TRAINLOG_SHELL_STABLE_ID_CAPACITY];
size_t selected;
} TrainlogOverlay;
typedef struct TrainlogOverlayStack {
TrainlogOverlay items[TRAINLOG_SHELL_OVERLAY_LIMIT];
size_t count;
} TrainlogOverlayStack;
typedef struct TrainlogListState {
char selected_id[TRAINLOG_SHELL_STABLE_ID_CAPACITY];
size_t selected_index;
size_t viewport_start;
size_t visible_rows;
size_t item_count;
bool total_known;
bool more_available;
} TrainlogListState;
typedef struct TrainlogSearchState {
char text[TRAINLOG_SHELL_SEARCH_CAPACITY];
size_t bytes;
size_t cursor;
bool open;
bool focused;
bool capacity_error;
} TrainlogSearchState;
typedef enum TrainlogFormResult {
TRAINLOG_FORM_IGNORED = 0,
TRAINLOG_FORM_EDITED,
TRAINLOG_FORM_SUBMIT,
TRAINLOG_FORM_CANCEL,
TRAINLOG_FORM_NEXT,
TRAINLOG_FORM_PREVIOUS,
TRAINLOG_FORM_OPEN_NAVIGATION,
TRAINLOG_FORM_OPEN_ACTIONS
} TrainlogFormResult;
typedef struct TrainlogFormField {
char text[TRAINLOG_SHELL_SEARCH_CAPACITY];
size_t bytes;
size_t cursor;
bool active;
bool capacity_error;
} TrainlogFormField;
typedef struct TrainlogDurabilityState {
bool session_draft;
bool session_dirty;
bool generator_configuration_dirty;
bool generator_preview;
bool generator_preview_dirty;
bool transient_form_dirty;
} TrainlogDurabilityState;
typedef enum TrainlogLeaveDecision {
TRAINLOG_LEAVE_ALLOW = 0,
TRAINLOG_LEAVE_CONFIRM_KEEP,
TRAINLOG_LEAVE_CONFIRM_DISCARD
} TrainlogLeaveDecision;
/* WHY: one geometry policy keeps renderers free of terminal-specific bounds.
* CONTRACT: NULL layout is ignored; otherwise output is fully reset. Usable
* requires at least 72x20; sidebar thresholds are 100x26 and 120x32. */
void trainlog_shell_layout_compute(int columns, int rows,
TrainlogShellLayout *layout);
/* CONTRACT: returns an all-zero rectangle for NULL/unusable layout; preferred
* nonpositive/oversize dimensions are clamped. Result is a value copy. */
TrainlogRect trainlog_shell_overlay_rect(const TrainlogShellLayout *layout,
int preferred_width,
int preferred_height);
/* CONTRACT: false for NULL or geometrically invalid rectangles; no mutation. */
bool trainlog_shell_rect_contains(const TrainlogRect *outer,
const TrainlogRect *inner);
/* CONTRACT: NULL is ignored. init selects Home. open copies at most 79 bytes
* of stable_id (NULL means empty), keeps at most eight back entries, and
* returns false only for NULL navigation. back mutates only when history exists. */
void trainlog_navigation_init(TrainlogNavigationState *navigation);
bool trainlog_navigation_open(TrainlogNavigationState *navigation,
TrainlogAppRoute route,
const char *stable_id);
bool trainlog_navigation_back(TrainlogNavigationState *navigation);
/* CONTRACT: clear ignores NULL. add copies the action struct, but borrows its
* identifier and label strings for the model lifetime; it rejects NULL strings,
* duplicate stable identifiers and the 24-action bound. Lookup returns a
* borrowed item or NULL. Priority
* ordering is enabled actions by ascending priority, then insertion order. */
void trainlog_actions_clear(TrainlogActionModel *model);
bool trainlog_actions_add(TrainlogActionModel *model,
const TrainlogAction *action);
const TrainlogAction *trainlog_actions_find_key(const TrainlogActionModel *model,
int key);
const TrainlogAction *trainlog_actions_at_priority(const TrainlogActionModel *model,
size_t position);
/* CONTRACT: init ignores NULL. push copies up to 79 stable-ID bytes and fails
* for NULL/full (three overlays) stacks. top borrows an item until the stack
* mutates. pop fails when empty; on success it mutates the stack and writes
* only non-NULL outputs, whose stable-ID buffer must hold 80 bytes. */
void trainlog_overlays_init(TrainlogOverlayStack *stack);
bool trainlog_overlays_push(TrainlogOverlayStack *stack,
TrainlogOverlayType type,
TrainlogFocusTarget restore_focus,
const char *restore_stable_id);
const TrainlogOverlay *trainlog_overlays_top(const TrainlogOverlayStack *stack);
bool trainlog_overlays_pop(TrainlogOverlayStack *stack,
TrainlogFocusTarget *restore_focus,
char restore_stable_id[TRAINLOG_SHELL_STABLE_ID_CAPACITY]);
/* CONTRACT: list state never owns stable_ids: callers keep every supplied
* string and array alive through the matching move/set operation. set accepts
* zero items (clears selection), treats visible_rows zero as one, and copies a
* selected stable ID with the 79-byte truncation rule. move ignores NULL,
* empty, or out-of-range state and clamps deltas at list bounds. */
void trainlog_list_init(TrainlogListState *list);
void trainlog_list_set_items(TrainlogListState *list,
const char *const *stable_ids,
size_t count,
size_t visible_rows,
bool total_known,
bool more_available);
void trainlog_list_move(TrainlogListState *list,
const char *const *stable_ids,
int delta);
/* CONTRACT: value is borrowed and NULL means empty. output must be non-NULL
* with nonzero capacity or is untouched; otherwise it is NUL-terminated.
* maximum_cells <= 0 yields empty output. Invalid UTF-8 is copied bytewise;
* output stays complete UTF-8 where possible and uses a final ellipsis when
* truncated by display cells or capacity. */
void trainlog_shell_format_list_label(const char *value,
int maximum_cells,
char *output,
size_t output_capacity);
/* CONTRACT: search/form text is owned inline storage, capped at 200 UTF-8
* bytes plus NUL. init ignores NULL; insert borrows utf8 for this call and
* rejects NULL, invalid UTF-8, overflow, and partial codepoints without
* mutation except capacity_error. Cursor operations ignore NULL and preserve
* UTF-8 boundaries. Escape clears nonempty text (false) or closes empty input
* (true). Form init copies/truncates initial input; handle ignores NULL and
* returns the exact result enum, including F6/F7 shell requests. */
void trainlog_search_init(TrainlogSearchState *search);
bool trainlog_search_insert(TrainlogSearchState *search,
const char *utf8,
size_t length);
bool trainlog_search_backspace(TrainlogSearchState *search);
void trainlog_search_home(TrainlogSearchState *search);
void trainlog_search_end(TrainlogSearchState *search);
void trainlog_search_left(TrainlogSearchState *search);
void trainlog_search_right(TrainlogSearchState *search);
/* Returns true when Escape closes the empty editor; non-empty Escape clears. */
bool trainlog_search_escape(TrainlogSearchState *search);
void trainlog_form_init(TrainlogFormField *field, const char *initial_value);
TrainlogFormResult trainlog_form_handle(TrainlogFormField *field, int key);
/* CONTRACT: title returns a borrowed static string, including a fallback for
* invalid routes. section returns a route value without mutation. Leave
* decision reads state only (NULL allows leaving); explicit discard selects
* discard confirmation when transient durable state exists. discard ignores
* NULL and clears only transient in-memory flags: it performs no DB write. */
const char *trainlog_route_title(TrainlogAppRoute route);
TrainlogAppRoute trainlog_route_section(TrainlogAppRoute route);
TrainlogLeaveDecision trainlog_shell_leave_decision(
const TrainlogDurabilityState *state,
bool explicit_discard);
void trainlog_shell_discard_transient(TrainlogDurabilityState *state);
#endif

View file

@ -11,7 +11,7 @@
#include "trainlog/model.h"
#include "trainlog/status.h"
#define TRAINLOG_DATABASE_SCHEMA_VERSION 11
#define TRAINLOG_DATABASE_SCHEMA_VERSION 12
typedef struct TrainlogDatabase TrainlogDatabase;
@ -30,6 +30,8 @@ typedef struct TrainlogCustomEquipment {
char load_semantics[32];
} TrainlogCustomEquipment;
#define TRAINLOG_CUSTOM_EQUIPMENT_PAGE_MAX 128U
typedef enum TrainlogEquipmentOrigin {
TRAINLOG_EQUIPMENT_SUPPLIED = 0,
TRAINLOG_EQUIPMENT_CUSTOM,
@ -55,6 +57,25 @@ TrainlogStatus trainlog_database_list_custom_equipment(
size_t capacity,
size_t *output_count
);
/* CONTRACT: reads at most capacity + 1 ordered definitions, so callers can
* filter pages before imposing a presentation cap. database and output are
* borrowed for the call; the caller owns copied strings. This read-only API
* retains no resources and writes no persistence. capacity is 1..128 and all
* pointer arguments are required. output_count is at most capacity; output_more
* is true exactly when one additional ordered row exists. CONTRACT: output,
* output_count, and output_more are valid only when this returns
* TRAINLOG_STATUS_OK; on every other status callers must disregard all output,
* including any storage copied before the failure. offset is valid only while
* data is unchanged; refresh after writes. It is neither a durable cursor nor
* an idempotency mechanism. */
TrainlogStatus trainlog_database_list_custom_equipment_page(
TrainlogDatabase *database,
size_t offset,
TrainlogCustomEquipment *output,
size_t capacity,
size_t *output_count,
bool *output_more
);
/* CONTRACT: an occurrence ID always resolves to a visible value. Unknown IDs
* are returned verbatim with TRAINLOG_EQUIPMENT_UNKNOWN, never hidden. */
TrainlogStatus trainlog_database_resolve_equipment(
@ -143,6 +164,53 @@ TrainlogStatus trainlog_database_list_exercises(
size_t *output_count
);
/**
* @brief Resolve a current or merged exercise identity to its canonical ID.
*
* CONTRACT: current catalogue IDs resolve to themselves; durable legacy IDs
* resolve through exercise_aliases. Unknown IDs return NOT_FOUND. The output
* buffer is caller-owned and must hold TRAINLOG_ID_MAX + 1 bytes.
*/
TrainlogStatus trainlog_database_resolve_exercise_id(
TrainlogDatabase *database,
const char *exercise_id,
char *output_canonical_id,
size_t output_capacity
);
/**
* @brief Atomically merge one current catalogue exercise into another.
*
* CONTRACT: source and canonical must be distinct current catalogue IDs.
* Tracking/recording/data-field or conflicting non-empty primary-zone
* profiles reject with CONFLICT and leave the database untouched. Compatible
* direct zones are unioned; every occurrence is repointed without rewriting
* its stable entry ID or any owned actual/planning data. Existing aliases to
* source collapse directly to canonical and source becomes a durable alias.
*/
TrainlogStatus trainlog_database_merge_exercises(
TrainlogDatabase *database,
const char *source_exercise_id,
const char *canonical_exercise_id
);
typedef struct TrainlogExerciseMergePreview {
size_t occurrences;
size_t performed_sets;
size_t continuous_activities;
size_t max_results;
size_t associated_equipment;
size_t body_zones;
} TrainlogExerciseMergePreview;
/* CONTRACT: counts describe source-owned records which the atomic merge will
* repoint or union. Unknown IDs return NOT_FOUND and output is never partial. */
TrainlogStatus trainlog_database_preview_exercise_merge(
TrainlogDatabase *database,
const char *source_exercise_id,
TrainlogExerciseMergePreview *output
);
/**
* @brief Atomically replace one exercise's direct body-zone relations.
*
@ -500,6 +568,15 @@ TrainlogStatus trainlog_database_latest_explicit_max_context(
TrainlogLatestExplicitMax *output
);
/* Same chronological contract, additionally restricted to one exact nonempty
* equipment ID. This is the compatibility reader for user-directed %MAX. */
TrainlogStatus trainlog_database_latest_explicit_max_equipment_context(
TrainlogDatabase *database,
const char *exercise_id,
const char *equipment_id,
TrainlogLatestExplicitMax *output
);
/* TRAINLOG_SESSION_EDIT_API */

View file

@ -57,4 +57,20 @@ TrainlogStatus trainlog_measured_max_working_load(
double *output_kg
);
/**
* @brief Calculate a user-directed target from one compatible explicit MAX.
*
* Compatibility is exact exercise ownership (established by the caller's
* exercise-scoped latest-max query), exact nonempty equipment ID, and external
* resistance. percent must be the integer range 1..100. No rounding,
* recommendation, or percentage metadata is persisted by this function.
*/
TrainlogStatus trainlog_measured_max_target_load(
const TrainlogLatestExplicitMax *latest,
const char *equipment_id,
TrainlogLoadMode equipment_load_semantics,
int percent,
double *output_kg
);
#endif

View file

@ -14,6 +14,7 @@
typedef struct TrainlogTerminal TrainlogTerminal;
typedef struct TrainlogPanel TrainlogPanel;
typedef struct TrainlogSurface TrainlogSurface;
typedef enum TrainlogKey {
TRAINLOG_KEY_NONE = -1,
@ -36,6 +37,8 @@ typedef enum TrainlogKey {
TRAINLOG_KEY_F3,
TRAINLOG_KEY_F4,
TRAINLOG_KEY_F5,
TRAINLOG_KEY_F6,
TRAINLOG_KEY_F7,
TRAINLOG_KEY_SHIFT_TAB
} TrainlogKey;
@ -77,6 +80,32 @@ int trainlog_terminal_get_key(TrainlogTerminal *terminal);
bool trainlog_terminal_read_unicode(TrainlogTerminal *terminal, int *codepoint,
char utf8[5]);
bool trainlog_terminal_push_key(TrainlogTerminal *terminal, int key);
/* Refreshes Notcurses geometry after a resize event. */
bool trainlog_terminal_refresh_geometry(TrainlogTerminal *terminal);
/* WHY: shell chrome and overlays have independent Notcurses ownership. The
* rectangle is validated against the standard plane at create/resize time,
* and every write is clipped by this adapter rather than parent inheritance. */
TrainlogSurface *trainlog_surface_create(TrainlogTerminal *terminal,
const char *name,
int top, int left,
int height, int width);
bool trainlog_surface_set_rect(TrainlogSurface *surface,
int top, int left,
int height, int width);
void trainlog_surface_destroy(TrainlogSurface *surface);
void trainlog_surface_erase(TrainlogSurface *surface);
void trainlog_surface_set_role(TrainlogSurface *surface,
TrainlogColorRole foreground,
unsigned background_rgb,
TrainlogTextStyle style);
void trainlog_surface_printf(TrainlogSurface *surface,
int row, int column,
const char *format, ...)
__attribute__((format(printf, 4, 5)));
void trainlog_surface_draw(TrainlogSurface *surface,
int row, int column, uint32_t codepoint);
void trainlog_surface_move_top(TrainlogSurface *surface);
/* Private screen-port helpers. Panels are lightweight coordinate views over
* the one standard plane, not independently owned terminal surfaces. */

View file

@ -15,7 +15,9 @@ typedef enum TrainlogColorRole {
TRAINLOG_COLOR_WARNING = 3,
TRAINLOG_COLOR_ERROR = 4,
TRAINLOG_COLOR_MUTED = 5,
TRAINLOG_COLOR_GRAPH = 6
TRAINLOG_COLOR_GRAPH = 6,
TRAINLOG_COLOR_INFO = 7,
TRAINLOG_COLOR_NOTICE = 8
} TrainlogColorRole;
/*
@ -28,6 +30,21 @@ typedef uint32_t TrainlogTextStyle;
#define TRAINLOG_TEXT_BOLD ((TrainlogTextStyle)0x0001U)
#define TRAINLOG_TEXT_REVERSE ((TrainlogTextStyle)0x0002U)
/* APP_SHELL_V1 platform tokens. Values are RGB, independent of Notcurses. */
#define TRAINLOG_RGB_CRUST 0x11111bU
#define TRAINLOG_RGB_MANTLE 0x181825U
#define TRAINLOG_RGB_BASE 0x1e1e2eU
#define TRAINLOG_RGB_SURFACE0 0x313244U
#define TRAINLOG_RGB_SURFACE1 0x45475aU
#define TRAINLOG_RGB_TEXT 0xcdd6f4U
#define TRAINLOG_RGB_SUBTEXT 0xbac2deU
#define TRAINLOG_RGB_LAVENDER 0xb4befeU
#define TRAINLOG_RGB_SUCCESS 0xa6e3a1U
#define TRAINLOG_RGB_WARNING 0xf9e2afU
#define TRAINLOG_RGB_ERROR 0xf38ba8U
#define TRAINLOG_RGB_INFO 0x89b4faU
#define TRAINLOG_RGB_NOTICE 0xfab387U
TrainlogTextStyle trainlog_theme_style(TrainlogColorRole role);
#endif

View file

@ -129,6 +129,7 @@ trainlog_core_dep = declare_dependency(
trainlog_tui_sources = files(
'src/main.c',
'src/app_shell.c',
'src/sync_screen_action.c',
'src/terminal.c',
'src/theme.c',
@ -542,6 +543,12 @@ test(
args: [meson.project_source_root() / 'tests/test_body_zone_catalog.py'],
)
test(
'exercise_alias_import_validation',
python3_trainlog_tests,
args: [meson.project_source_root() / 'tests/test_exercise_alias_import.py'],
)
test_sync_direction = executable(
'test_sync_direction',
'tests/test_sync_direction.c',
@ -551,6 +558,15 @@ test_sync_direction = executable(
test('sync_direction', test_sync_direction)
test_sync_body_zone_wiring = executable(
'test_sync_body_zone_wiring',
'tests/test_sync_body_zone_wiring.c',
dependencies: trainlog_core_dep,
c_args: strict_c_args,
)
test('sync_body_zone_wiring', test_sync_body_zone_wiring)
test_sync_history = executable(
'test_sync_history',
'tests/test_sync_history.c',
@ -606,7 +622,7 @@ test_terminal_input = executable(
'tests/test_terminal_input.c',
'src/terminal.c',
include_directories: trainlog_include,
dependencies: notcurses_dep,
dependencies: [notcurses_dep, utf8proc_dep],
c_args: strict_c_args,
)
@ -618,6 +634,7 @@ test(
test_tui_workflows = executable(
'test_tui_workflows',
'tests/test_tui_workflows.c',
'src/app_shell.c',
'src/theme.c',
'src/sync_screen_action.c',
dependencies: trainlog_core_dep,
@ -625,3 +642,13 @@ test_tui_workflows = executable(
)
test('tui_workflows', test_tui_workflows)
test_app_shell = executable(
'test_app_shell',
'tests/test_app_shell.c',
'src/app_shell.c',
dependencies: trainlog_core_dep,
c_args: strict_c_args,
)
test('app_shell', test_app_shell)

569
tui/src/app_shell.c Normal file
View file

@ -0,0 +1,569 @@
/**
* @file app_shell.c
* @brief Pure state machinery shared by the Notcurses application shell.
*/
#include "trainlog/app_shell.h"
#include <stdio.h>
#include <string.h>
#include <utf8proc.h>
#include "trainlog/terminal.h"
static void copy_id(char output[TRAINLOG_SHELL_STABLE_ID_CAPACITY],
const char *value)
{
(void)snprintf(output, TRAINLOG_SHELL_STABLE_ID_CAPACITY, "%s",
value != NULL ? value : "");
}
void trainlog_shell_layout_compute(int columns, int rows,
TrainlogShellLayout *layout)
{
int content_x;
if (layout == NULL) return;
(void)memset(layout, 0, sizeof(*layout));
layout->rows = rows;
layout->columns = columns;
layout->usable = columns >= TRAINLOG_SHELL_MIN_COLUMNS &&
rows >= TRAINLOG_SHELL_MIN_ROWS;
if (!layout->usable) return;
/* CONTRACT: chrome owns four rows on every valid geometry. Content and
* overlays can therefore never clip or overwrite the footer. */
layout->header = (TrainlogRect){0, 0, 2, columns};
layout->footer = (TrainlogRect){rows - 2, 0, 2, columns};
layout->sidebar_visible = columns >= 100 && rows >= 26;
layout->sidebar_expanded = columns >= 120 && rows >= 32;
content_x = layout->sidebar_visible ? 23 : 0;
if (layout->sidebar_visible) {
layout->sidebar = (TrainlogRect){2, 0, rows - 4,
TRAINLOG_SHELL_SIDEBAR_COLUMNS};
layout->separator = (TrainlogRect){2, 22, rows - 4, 1};
}
layout->content = (TrainlogRect){2, content_x, rows - 4,
columns - content_x};
}
TrainlogRect trainlog_shell_overlay_rect(const TrainlogShellLayout *layout,
int preferred_width,
int preferred_height)
{
TrainlogRect result = {0, 0, 0, 0};
int maximum_width;
int maximum_height;
if (layout == NULL || !layout->usable) return result;
maximum_width = layout->columns >= 100 ? 80 : layout->columns;
if (layout->columns == 100 && maximum_width > 74) maximum_width = 74;
if (layout->columns == 80 && maximum_width > 78) maximum_width = 78;
maximum_height = layout->content.height;
if (layout->columns >= 120 && maximum_height > 29) maximum_height = 29;
if (layout->columns == 100 && maximum_height > 24) maximum_height = 24;
result.width = preferred_width > 0 && preferred_width < maximum_width
? preferred_width : maximum_width;
result.height = preferred_height > 0 && preferred_height < maximum_height
? preferred_height : maximum_height;
result.x = (layout->columns - result.width) / 2;
result.y = layout->content.y +
(layout->content.height - result.height) / 2;
return result;
}
bool trainlog_shell_rect_contains(const TrainlogRect *outer,
const TrainlogRect *inner)
{
return outer != NULL && inner != NULL && inner->height >= 0 &&
inner->width >= 0 && inner->y >= outer->y && inner->x >= outer->x &&
inner->y + inner->height <= outer->y + outer->height &&
inner->x + inner->width <= outer->x + outer->width;
}
void trainlog_navigation_init(TrainlogNavigationState *navigation)
{
if (navigation == NULL) return;
(void)memset(navigation, 0, sizeof(*navigation));
navigation->current.route = TRAINLOG_ROUTE_HOME;
}
bool trainlog_navigation_open(TrainlogNavigationState *navigation,
TrainlogAppRoute route,
const char *stable_id)
{
if (navigation == NULL) return false;
if (navigation->current.route == route &&
strcmp(navigation->current.stable_id, stable_id != NULL ? stable_id : "") == 0)
return true;
if (navigation->back_count == TRAINLOG_SHELL_ROUTE_DEPTH) {
(void)memmove(&navigation->back[0], &navigation->back[1],
(TRAINLOG_SHELL_ROUTE_DEPTH - 1U) * sizeof(navigation->back[0]));
--navigation->back_count;
}
navigation->back[navigation->back_count++] = navigation->current;
navigation->current.route = route;
copy_id(navigation->current.stable_id, stable_id);
return true;
}
bool trainlog_navigation_back(TrainlogNavigationState *navigation)
{
if (navigation == NULL || navigation->back_count == 0U) return false;
navigation->current = navigation->back[--navigation->back_count];
return true;
}
void trainlog_actions_clear(TrainlogActionModel *model)
{
if (model != NULL) (void)memset(model, 0, sizeof(*model));
}
bool trainlog_actions_add(TrainlogActionModel *model,
const TrainlogAction *action)
{
size_t index;
if (model == NULL || action == NULL || action->identifier == NULL ||
action->label == NULL || model->count >= TRAINLOG_SHELL_ACTION_LIMIT)
return false;
/* INVARIANT: identifier is the action's stable identity. Keeping it unique
* prevents the footer and F7 registry from advertising the same semantic
* action twice when contextual and shell-wide contributors overlap. */
for (index = 0U; index < model->count; ++index)
if (strcmp(model->items[index].identifier, action->identifier) == 0)
return false;
model->items[model->count++] = *action;
return true;
}
const TrainlogAction *trainlog_actions_find_key(const TrainlogActionModel *model,
int key)
{
size_t index;
if (model == NULL) return NULL;
for (index = 0U; index < model->count; ++index) {
if (model->items[index].enabled && model->items[index].key == key)
return &model->items[index];
}
return NULL;
}
const TrainlogAction *trainlog_actions_at_priority(const TrainlogActionModel *model,
size_t position)
{
const TrainlogAction *result = NULL;
size_t rank;
size_t index;
if (model == NULL) return NULL;
for (rank = 0U; rank <= position; ++rank) {
result = NULL;
for (index = 0U; index < model->count; ++index) {
const TrainlogAction *candidate = &model->items[index];
size_t prior = 0U;
size_t other;
if (!candidate->enabled) continue;
for (other = 0U; other < model->count; ++other) {
if (!model->items[other].enabled) continue;
if (model->items[other].priority < candidate->priority ||
(model->items[other].priority == candidate->priority &&
other < index)) ++prior;
}
if (prior == rank) { result = candidate; break; }
}
if (result == NULL || rank == position) break;
}
return result;
}
void trainlog_overlays_init(TrainlogOverlayStack *stack)
{
if (stack != NULL) (void)memset(stack, 0, sizeof(*stack));
}
bool trainlog_overlays_push(TrainlogOverlayStack *stack,
TrainlogOverlayType type,
TrainlogFocusTarget restore_focus,
const char *restore_stable_id)
{
TrainlogOverlay *overlay;
if (stack == NULL || stack->count >= TRAINLOG_SHELL_OVERLAY_LIMIT) return false;
overlay = &stack->items[stack->count++];
(void)memset(overlay, 0, sizeof(*overlay));
overlay->type = type;
overlay->restore_focus = restore_focus;
copy_id(overlay->restore_stable_id, restore_stable_id);
return true;
}
const TrainlogOverlay *trainlog_overlays_top(const TrainlogOverlayStack *stack)
{
return stack != NULL && stack->count > 0U
? &stack->items[stack->count - 1U] : NULL;
}
bool trainlog_overlays_pop(TrainlogOverlayStack *stack,
TrainlogFocusTarget *restore_focus,
char restore_stable_id[TRAINLOG_SHELL_STABLE_ID_CAPACITY])
{
TrainlogOverlay *overlay;
if (stack == NULL || stack->count == 0U) return false;
overlay = &stack->items[--stack->count];
if (restore_focus != NULL) *restore_focus = overlay->restore_focus;
if (restore_stable_id != NULL)
copy_id(restore_stable_id, overlay->restore_stable_id);
(void)memset(overlay, 0, sizeof(*overlay));
return true;
}
void trainlog_list_init(TrainlogListState *list)
{
if (list != NULL) (void)memset(list, 0, sizeof(*list));
}
void trainlog_list_set_items(TrainlogListState *list,
const char *const *stable_ids,
size_t count,
size_t visible_rows,
bool total_known,
bool more_available)
{
size_t index;
size_t selected = 0U;
bool found = false;
if (list == NULL) return;
for (index = 0U; index < count && stable_ids != NULL; ++index) {
if (strcmp(list->selected_id, stable_ids[index]) == 0) {
selected = index; found = true; break;
}
}
if (!found && count > 0U) {
selected = list->selected_index < count ? list->selected_index : count - 1U;
copy_id(list->selected_id, stable_ids[selected]);
} else if (count == 0U) {
list->selected_id[0] = '\0'; selected = 0U;
}
list->selected_index = selected;
list->item_count = count;
list->visible_rows = visible_rows > 0U ? visible_rows : 1U;
list->total_known = total_known;
list->more_available = more_available;
if (selected < list->viewport_start) list->viewport_start = selected;
if (selected >= list->viewport_start + list->visible_rows)
list->viewport_start = selected - list->visible_rows + 1U;
if (count <= list->visible_rows) list->viewport_start = 0U;
else if (list->viewport_start > count - list->visible_rows)
list->viewport_start = count - list->visible_rows;
}
void trainlog_list_move(TrainlogListState *list,
const char *const *stable_ids,
int delta)
{
size_t next;
if (list == NULL || stable_ids == NULL || list->item_count == 0U) return;
if (delta < 0) {
size_t amount = (size_t)(-(long long)delta);
next = amount > list->selected_index ? 0U : list->selected_index - amount;
} else {
size_t amount = (size_t)delta;
next = amount > list->item_count - 1U - list->selected_index
? list->item_count - 1U : list->selected_index + amount;
}
list->selected_index = next;
copy_id(list->selected_id, stable_ids[next]);
if (next < list->viewport_start) list->viewport_start = next;
if (next >= list->viewport_start + list->visible_rows)
list->viewport_start = next - list->visible_rows + 1U;
}
void trainlog_shell_format_list_label(const char *value,
int maximum_cells,
char *output,
size_t output_capacity)
{
const char *input = value != NULL ? value : "";
size_t input_bytes = 0U;
size_t output_bytes = 0U;
int cells = 0;
bool truncated = false;
if (output == NULL || output_capacity == 0U) return;
output[0] = '\0';
if (maximum_cells <= 0) return;
/* INVARIANT: reserve one display cell for the truncation marker before
* copying a code point. This keeps labels valid UTF-8 and makes clipped
* stable-ID-backed rows visibly distinct from complete names. */
while (input[input_bytes] != '\0') {
utf8proc_int32_t codepoint;
utf8proc_ssize_t parsed = utf8proc_iterate(
(const utf8proc_uint8_t *)input + input_bytes, -1, &codepoint);
int width;
if (parsed <= 0) { parsed = 1; codepoint = (unsigned char)input[input_bytes]; }
width = utf8proc_charwidth(codepoint);
if (width < 0) width = 1;
if (cells + width > maximum_cells ||
output_bytes + (size_t)parsed >= output_capacity) {
truncated = true;
break;
}
(void)memcpy(output + output_bytes, input + input_bytes, (size_t)parsed);
output_bytes += (size_t)parsed;
input_bytes += (size_t)parsed;
cells += width;
}
if (input[input_bytes] != '\0') truncated = true;
if (truncated) {
static const char ellipsis[] = "";
while (output_bytes > 0U && cells >= maximum_cells) {
utf8proc_int32_t codepoint;
size_t start = output_bytes - 1U;
while (start > 0U && ((unsigned char)output[start] & 0xc0U) == 0x80U)
--start;
if (utf8proc_iterate((const utf8proc_uint8_t *)output + start,
(utf8proc_ssize_t)(output_bytes - start), &codepoint) > 0) {
int width = utf8proc_charwidth(codepoint);
cells -= width > 0 ? width : 1;
}
output_bytes = start;
}
if (output_bytes + sizeof(ellipsis) <= output_capacity) {
(void)memcpy(output + output_bytes, ellipsis, sizeof(ellipsis));
return;
}
}
output[output_bytes] = '\0';
}
void trainlog_search_init(TrainlogSearchState *search)
{
if (search == NULL) return;
(void)memset(search, 0, sizeof(*search));
}
bool trainlog_search_insert(TrainlogSearchState *search,
const char *utf8,
size_t length)
{
utf8proc_int32_t codepoint;
utf8proc_ssize_t parsed;
if (search == NULL || utf8 == NULL || length == 0U ||
search->bytes + length >= TRAINLOG_SHELL_SEARCH_CAPACITY) {
if (search != NULL) search->capacity_error = true;
return false;
}
parsed = utf8proc_iterate((const utf8proc_uint8_t *)utf8,
(utf8proc_ssize_t)length, &codepoint);
if (parsed <= 0 || (size_t)parsed != length || codepoint < 0x20 ||
codepoint == 0x7f) return false;
(void)memmove(search->text + search->cursor + length,
search->text + search->cursor, search->bytes - search->cursor + 1U);
(void)memcpy(search->text + search->cursor, utf8, length);
search->cursor += length;
search->bytes += length;
search->capacity_error = false;
return true;
}
static size_t next_grapheme_boundary(const char *text, size_t bytes, size_t start)
{
utf8proc_int32_t previous;
utf8proc_ssize_t parsed;
size_t cursor = start;
utf8proc_int32_t state = 0;
if (text == NULL || start >= bytes) return bytes;
parsed = utf8proc_iterate((const utf8proc_uint8_t *)text + cursor,
(utf8proc_ssize_t)(bytes - cursor), &previous);
if (parsed <= 0) return start + 1U;
cursor += (size_t)parsed;
while (cursor < bytes) {
utf8proc_int32_t current;
parsed = utf8proc_iterate((const utf8proc_uint8_t *)text + cursor,
(utf8proc_ssize_t)(bytes - cursor), &current);
if (parsed <= 0 || utf8proc_grapheme_break_stateful(previous, current,
&state) != 0) break;
previous = current;
cursor += (size_t)parsed;
}
return cursor;
}
static size_t previous_grapheme_boundary(const char *text, size_t bytes,
size_t cursor)
{
size_t previous = 0U;
size_t next = 0U;
while (next < cursor && next < bytes) {
previous = next;
next = next_grapheme_boundary(text, bytes, next);
}
return previous;
}
bool trainlog_search_backspace(TrainlogSearchState *search)
{
size_t start;
if (search == NULL || search->cursor == 0U) return false;
start = previous_grapheme_boundary(search->text, search->bytes,
search->cursor);
(void)memmove(search->text + start, search->text + search->cursor,
search->bytes - search->cursor + 1U);
search->bytes -= search->cursor - start;
search->cursor = start;
search->capacity_error = false;
return true;
}
void trainlog_search_home(TrainlogSearchState *search)
{
if (search != NULL) search->cursor = 0U;
}
void trainlog_search_end(TrainlogSearchState *search)
{
if (search != NULL) search->cursor = search->bytes;
}
void trainlog_search_left(TrainlogSearchState *search)
{
if (search == NULL || search->cursor == 0U) return;
search->cursor = previous_grapheme_boundary(search->text, search->bytes,
search->cursor);
}
void trainlog_search_right(TrainlogSearchState *search)
{
if (search == NULL || search->cursor >= search->bytes) return;
search->cursor = next_grapheme_boundary(search->text, search->bytes,
search->cursor);
}
bool trainlog_search_escape(TrainlogSearchState *search)
{
if (search == NULL) return false;
if (search->bytes > 0U) {
search->text[0] = '\0'; search->bytes = 0U; search->cursor = 0U;
search->capacity_error = false; return false;
}
search->open = false; search->focused = false; return true;
}
void trainlog_form_init(TrainlogFormField *field, const char *initial_value)
{
size_t length;
if (field == NULL) return;
(void)memset(field, 0, sizeof(*field));
length = initial_value != NULL ? strlen(initial_value) : 0U;
if (length > TRAINLOG_SHELL_SEARCH_CAPACITY - 1U)
length = TRAINLOG_SHELL_SEARCH_CAPACITY - 1U;
if (length > 0U) (void)memcpy(field->text, initial_value, length);
field->text[length] = '\0';
field->bytes = length;
field->cursor = length;
field->active = true;
}
TrainlogFormResult trainlog_form_handle(TrainlogFormField *field, int key)
{
TrainlogSearchState editor;
bool changed = false;
if (field == NULL || !field->active) return TRAINLOG_FORM_IGNORED;
if (key == TRAINLOG_KEY_ENTER || key == '\n') return TRAINLOG_FORM_SUBMIT;
if (key == TRAINLOG_KEY_ESCAPE || key == 27) return TRAINLOG_FORM_CANCEL;
if (key == TRAINLOG_KEY_TAB) return TRAINLOG_FORM_NEXT;
if (key == TRAINLOG_KEY_SHIFT_TAB) return TRAINLOG_FORM_PREVIOUS;
if (key == TRAINLOG_KEY_F6) return TRAINLOG_FORM_OPEN_NAVIGATION;
if (key == TRAINLOG_KEY_F7) return TRAINLOG_FORM_OPEN_ACTIONS;
(void)memset(&editor, 0, sizeof(editor));
(void)snprintf(editor.text, sizeof(editor.text), "%s", field->text);
editor.bytes = field->bytes;
editor.cursor = field->cursor;
editor.open = true;
editor.focused = true;
if (key == TRAINLOG_KEY_HOME) trainlog_search_home(&editor);
else if (key == TRAINLOG_KEY_END) trainlog_search_end(&editor);
else if (key == TRAINLOG_KEY_LEFT) trainlog_search_left(&editor);
else if (key == TRAINLOG_KEY_RIGHT) trainlog_search_right(&editor);
else if (key == TRAINLOG_KEY_BACKSPACE || key == TRAINLOG_KEY_DELETE)
changed = trainlog_search_backspace(&editor);
else if (key >= 0x20 && key <= 0x10ffff) {
char encoded[5] = "";
utf8proc_ssize_t bytes = utf8proc_encode_char((utf8proc_int32_t)key,
(utf8proc_uint8_t *)encoded);
if (bytes > 0) changed = trainlog_search_insert(&editor, encoded,
(size_t)bytes);
} else return TRAINLOG_FORM_IGNORED;
(void)snprintf(field->text, sizeof(field->text), "%s", editor.text);
field->bytes = editor.bytes;
field->cursor = editor.cursor;
field->capacity_error = editor.capacity_error;
return changed ? TRAINLOG_FORM_EDITED : TRAINLOG_FORM_IGNORED;
}
const char *trainlog_route_title(TrainlogAppRoute route)
{
switch (route) {
case TRAINLOG_ROUTE_HOME: return "Accueil";
case TRAINLOG_ROUTE_SESSIONS: return "Séances";
case TRAINLOG_ROUTE_SESSION_CURRENT: return "Séances / Séance en cours";
case TRAINLOG_ROUTE_SESSION_GENERATOR: return "Séances / Programmer";
case TRAINLOG_ROUTE_SESSION_MANUAL: return "Séances / Nouvelle séance";
case TRAINLOG_ROUTE_SESSIONS_COMPLETED: return "Séances / Effectuées";
case TRAINLOG_ROUTE_SESSION_DETAIL: return "Séances / Détail";
case TRAINLOG_ROUTE_EXERCISES: return "Exercices / Catalogue";
case TRAINLOG_ROUTE_EXERCISE_DETAIL: return "Exercices / Fiche";
case TRAINLOG_ROUTE_EXERCISE_KNOWLEDGE: return "Exercices / Connaissances";
case TRAINLOG_ROUTE_EXERCISE_PERFORMANCE: return "Statistiques / Performance";
case TRAINLOG_ROUTE_EXERCISE_MAX: return "Statistiques / MAX mesuré";
case TRAINLOG_ROUTE_EQUIPMENT: return "Équipements / Catalogue";
case TRAINLOG_ROUTE_EQUIPMENT_DETAIL: return "Équipements / Fiche";
case TRAINLOG_ROUTE_STATS: return "Statistiques";
case TRAINLOG_ROUTE_STATS_EXERCISE: return "Statistiques / Par exercice";
case TRAINLOG_ROUTE_BODY: return "Statistiques / Mensurations";
case TRAINLOG_ROUTE_BODY_DETAIL: return "Mensurations / Relevé";
case TRAINLOG_ROUTE_BODY_METRIC: return "Mensurations / Historique";
case TRAINLOG_ROUTE_BODY_TRENDS: return "Mensurations / 12 mois";
case TRAINLOG_ROUTE_BODY_GLOBAL: return "Mensurations / Vue globale";
case TRAINLOG_ROUTE_BODY_ANALYTICS: return "Mensurations / Analyse";
case TRAINLOG_ROUTE_MAX: return "Statistiques / Capacités MAX";
case TRAINLOG_ROUTE_SYNC: return "Synchronisation";
case TRAINLOG_ROUTE_SETTINGS: return "Paramètres";
default: return "Trainlog";
}
}
TrainlogAppRoute trainlog_route_section(TrainlogAppRoute route)
{
if (route >= TRAINLOG_ROUTE_SESSIONS && route <= TRAINLOG_ROUTE_SESSION_DETAIL)
return TRAINLOG_ROUTE_SESSIONS;
if (route == TRAINLOG_ROUTE_EXERCISE_DETAIL ||
route == TRAINLOG_ROUTE_EXERCISE_KNOWLEDGE) return TRAINLOG_ROUTE_EXERCISES;
if (route == TRAINLOG_ROUTE_EXERCISE_PERFORMANCE ||
route == TRAINLOG_ROUTE_EXERCISE_MAX) return TRAINLOG_ROUTE_STATS;
if (route == TRAINLOG_ROUTE_EQUIPMENT_DETAIL) return TRAINLOG_ROUTE_EQUIPMENT;
if (route == TRAINLOG_ROUTE_STATS_EXERCISE ||
(route >= TRAINLOG_ROUTE_BODY && route <= TRAINLOG_ROUTE_BODY_ANALYTICS) ||
route == TRAINLOG_ROUTE_MAX) return TRAINLOG_ROUTE_STATS;
return route;
}
TrainlogLeaveDecision trainlog_shell_leave_decision(
const TrainlogDurabilityState *state,
bool explicit_discard)
{
if (state == NULL) return TRAINLOG_LEAVE_ALLOW;
if (explicit_discard) return state->session_draft || state->session_dirty ||
state->generator_configuration_dirty || state->generator_preview ||
state->generator_preview_dirty || state->transient_form_dirty
? TRAINLOG_LEAVE_CONFIRM_DISCARD : TRAINLOG_LEAVE_ALLOW;
if (state->generator_preview || state->generator_preview_dirty ||
state->generator_configuration_dirty || state->transient_form_dirty)
return TRAINLOG_LEAVE_CONFIRM_KEEP;
/* A session draft is owned in memory by the run and ordinary navigation
* suspends it. Only q/discard asks to erase it. */
return TRAINLOG_LEAVE_ALLOW;
}
void trainlog_shell_discard_transient(TrainlogDurabilityState *state)
{
if (state != NULL) (void)memset(state, 0, sizeof(*state));
}

View file

@ -499,6 +499,23 @@ static const char *const CREATE_BODY_ZONE_RELATIONS_SQL =
"synced_state TEXT NOT NULL"
");";
/* WHY: deleted duplicate catalogue rows still arrive from offline peers.
* CONTRACT: aliases are a separate identity artifact, never an overload of a
* frozen mobile-export field. INVARIANT: canonical IDs always name a live
* exercise row and mappings are stored collapsed, so cycles/chains cannot be
* represented by valid database writes. */
static const char *const MIGRATE_V11_TO_V12_SQL =
"BEGIN IMMEDIATE;"
"CREATE TABLE exercise_aliases("
"source_exercise_id TEXT PRIMARY KEY,"
"canonical_exercise_id TEXT NOT NULL "
"REFERENCES exercises(exercise_id) ON DELETE RESTRICT,"
"CHECK(source_exercise_id<>canonical_exercise_id)"
");"
"CREATE INDEX exercise_aliases_canonical "
"ON exercise_aliases(canonical_exercise_id);"
"PRAGMA user_version = 12;COMMIT;";
static const char *const MIGRATE_V1_TO_V3_SQL =
"BEGIN IMMEDIATE;"
"ALTER TABLE sessions "
@ -1015,6 +1032,8 @@ static TrainlogStatus initialize_or_validate_schema(
status = execute_sql(database, MIGRATE_V9_TO_V10_SQL);
} else if (version == 10) {
status = TRAINLOG_STATUS_OK;
} else if (version == 11) {
status = TRAINLOG_STATUS_OK;
} else {
if (version == 1) {
status =
@ -1148,7 +1167,10 @@ static TrainlogStatus initialize_or_validate_schema(
}
if (status == TRAINLOG_STATUS_OK) {
status = migrate_v10_to_v11(database);
if (version < 11) status = migrate_v10_to_v11(database);
}
if (status == TRAINLOG_STATUS_OK) {
status = execute_sql(database, MIGRATE_V11_TO_V12_SQL);
}
if (
@ -1158,7 +1180,7 @@ static TrainlogStatus initialize_or_validate_schema(
set_open_diagnostic(
output_diagnostic,
output_diagnostic_capacity,
version == 0 ? "create schema v11" : "migrate database to schema v11",
version == 0 ? "create schema v12" : "migrate database to schema v12",
database->connection,
SQLITE_ERROR
);
@ -1393,6 +1415,84 @@ TrainlogStatus trainlog_database_list_custom_equipment(
return rc == SQLITE_DONE ? TRAINLOG_STATUS_OK : TRAINLOG_STATUS_DATABASE_ERROR;
}
static bool copy_custom_equipment_page_column(
sqlite3_stmt *statement, int column, char *output, size_t capacity)
{
const unsigned char *source;
int byte_count;
size_t length;
if (sqlite3_column_type(statement, column) != SQLITE_TEXT ||
output == NULL || capacity == 0U) return false;
source = sqlite3_column_text(statement, column);
byte_count = sqlite3_column_bytes(statement, column);
if (source == NULL || byte_count < 0) return false;
length = (size_t)byte_count;
/* SQLite TEXT may contain embedded NUL bytes; never silently truncate it. */
if (length >= capacity || memchr(source, '\0', length) != NULL) return false;
(void)memcpy(output, source, length);
output[length] = '\0';
return true;
}
TrainlogStatus trainlog_database_list_custom_equipment_page(
TrainlogDatabase *database, size_t offset, TrainlogCustomEquipment *output,
size_t capacity, size_t *output_count, bool *output_more)
{
static const char *const SQL =
"SELECT equipment_id,display_name,label_name,equipment_type,load_semantics "
"FROM custom_equipment ORDER BY display_name COLLATE NOCASE,equipment_id "
"LIMIT ?1 OFFSET ?2;";
sqlite3_stmt *statement = NULL;
size_t count = 0U;
int rc;
if (output != NULL && output_count != NULL && output_more != NULL &&
capacity >= 1U && capacity <= TRAINLOG_CUSTOM_EQUIPMENT_PAGE_MAX) {
*output_count = 0U;
*output_more = false;
}
if (database == NULL || output == NULL || output_count == NULL ||
output_more == NULL || capacity == 0U ||
capacity > TRAINLOG_CUSTOM_EQUIPMENT_PAGE_MAX ||
(uintmax_t)offset > (uintmax_t)INT64_MAX) {
return TRAINLOG_STATUS_INVALID_ARGUMENT;
}
rc = sqlite3_prepare_v2(database->connection, SQL, -1, &statement, NULL);
if (rc != SQLITE_OK) return TRAINLOG_STATUS_DATABASE_ERROR;
if (sqlite3_bind_int64(statement, 1, (sqlite3_int64)(capacity + 1U)) != SQLITE_OK ||
sqlite3_bind_int64(statement, 2, (sqlite3_int64)offset) != SQLITE_OK) {
(void)sqlite3_finalize(statement);
return TRAINLOG_STATUS_DATABASE_ERROR;
}
while ((rc = sqlite3_step(statement)) == SQLITE_ROW) {
TrainlogCustomEquipment item;
(void)memset(&item, 0, sizeof(item));
if (!copy_custom_equipment_page_column(statement, 0, item.equipment_id,
sizeof(item.equipment_id)) ||
!copy_custom_equipment_page_column(statement, 1, item.display_name,
sizeof(item.display_name)) ||
!copy_custom_equipment_page_column(statement, 2, item.label_name,
sizeof(item.label_name)) ||
!copy_custom_equipment_page_column(statement, 3, item.equipment_type,
sizeof(item.equipment_type)) ||
!copy_custom_equipment_page_column(statement, 4, item.load_semantics,
sizeof(item.load_semantics))) {
(void)sqlite3_finalize(statement);
return TRAINLOG_STATUS_DATABASE_ERROR;
}
if (count == capacity) {
*output_more = true;
break;
}
output[count++] = item;
}
if (sqlite3_finalize(statement) != SQLITE_OK) return TRAINLOG_STATUS_DATABASE_ERROR;
if (rc != SQLITE_DONE && rc != SQLITE_ROW) return TRAINLOG_STATUS_DATABASE_ERROR;
*output_count = count;
return TRAINLOG_STATUS_OK;
}
TrainlogStatus trainlog_database_resolve_equipment(
TrainlogDatabase *database, const char *equipment_id,
TrainlogResolvedEquipment *output
@ -1996,6 +2096,249 @@ TrainlogStatus trainlog_database_list_exercises(
return TRAINLOG_STATUS_OK;
}
TrainlogStatus trainlog_database_resolve_exercise_id(
TrainlogDatabase *database,
const char *exercise_id,
char *output_canonical_id,
size_t output_capacity
)
{
static const char *const SQL =
"SELECT exercise_id FROM exercises WHERE exercise_id=?1 "
"UNION ALL SELECT canonical_exercise_id FROM exercise_aliases "
"WHERE source_exercise_id=?1 LIMIT 1;";
sqlite3_stmt *statement = NULL;
const unsigned char *canonical;
int rc;
if (database == NULL || database->connection == NULL || exercise_id == NULL ||
exercise_id[0] == '\0' || output_canonical_id == NULL ||
output_capacity < TRAINLOG_ID_MAX + 1U)
return TRAINLOG_STATUS_INVALID_ARGUMENT;
rc = sqlite3_prepare_v2(database->connection, SQL, -1, &statement, NULL);
if (rc != SQLITE_OK || sqlite3_bind_text(statement, 1, exercise_id, -1,
SQLITE_TRANSIENT) != SQLITE_OK) {
if (statement != NULL) (void)sqlite3_finalize(statement);
return TRAINLOG_STATUS_DATABASE_ERROR;
}
rc = sqlite3_step(statement);
if (rc == SQLITE_DONE) {
(void)sqlite3_finalize(statement);
return TRAINLOG_STATUS_NOT_FOUND;
}
canonical = rc == SQLITE_ROW ? sqlite3_column_text(statement, 0) : NULL;
if (canonical == NULL || strlen((const char *)canonical) > TRAINLOG_ID_MAX) {
(void)sqlite3_finalize(statement);
return TRAINLOG_STATUS_DATABASE_ERROR;
}
(void)snprintf(output_canonical_id, output_capacity, "%s", canonical);
return sqlite3_finalize(statement) == SQLITE_OK ? TRAINLOG_STATUS_OK :
TRAINLOG_STATUS_DATABASE_ERROR;
}
static TrainlogStatus merge_bind_ids(
TrainlogDatabase *database,
const char *sql,
sqlite3_int64 source_row_id,
sqlite3_int64 canonical_row_id
)
{
sqlite3_stmt *statement = NULL;
int rc = sqlite3_prepare_v2(database->connection, sql, -1, &statement, NULL);
if (rc != SQLITE_OK || sqlite3_bind_parameter_count(statement) < 1 ||
sqlite3_bind_int64(statement, 1, source_row_id) != SQLITE_OK ||
(sqlite3_bind_parameter_count(statement) >= 2 &&
sqlite3_bind_int64(statement, 2, canonical_row_id) != SQLITE_OK)) {
if (statement != NULL) (void)sqlite3_finalize(statement);
return TRAINLOG_STATUS_DATABASE_ERROR;
}
rc = sqlite3_step(statement);
if (rc != SQLITE_DONE) {
(void)sqlite3_finalize(statement);
return rc == SQLITE_CONSTRAINT ? TRAINLOG_STATUS_CONFLICT :
TRAINLOG_STATUS_DATABASE_ERROR;
}
return sqlite3_finalize(statement) == SQLITE_OK ? TRAINLOG_STATUS_OK :
TRAINLOG_STATUS_DATABASE_ERROR;
}
TrainlogStatus trainlog_database_merge_exercises(
TrainlogDatabase *database,
const char *source_exercise_id,
const char *canonical_exercise_id
)
{
static const char *const PROFILE_SQL =
"SELECT s.id,c.id,s.tracking_mode,c.tracking_mode,"
"s.recording_mode,c.recording_mode,s.data_fields,c.data_fields,"
"(SELECT zone_id FROM exercise_body_zones WHERE exercise_row_id=s.id "
"AND role='primary'),(SELECT zone_id FROM exercise_body_zones "
"WHERE exercise_row_id=c.id AND role='primary') "
"FROM exercises s JOIN exercises c ON s.exercise_id=?1 "
"AND c.exercise_id=?2;";
sqlite3_stmt *statement = NULL;
sqlite3_int64 source_row_id, canonical_row_id;
const char *source_primary = NULL, *canonical_primary = NULL;
char primary[TRAINLOG_ZONE_ID_MAX + 1U] = "";
TrainlogStatus status = TRAINLOG_STATUS_DATABASE_ERROR;
int rc;
if (database == NULL || database->connection == NULL ||
source_exercise_id == NULL || canonical_exercise_id == NULL ||
source_exercise_id[0] == '\0' || canonical_exercise_id[0] == '\0' ||
strcmp(source_exercise_id, canonical_exercise_id) == 0)
return TRAINLOG_STATUS_INVALID_ARGUMENT;
if (execute_sql(database, "BEGIN IMMEDIATE;") != TRAINLOG_STATUS_OK)
return TRAINLOG_STATUS_DATABASE_ERROR;
rc = sqlite3_prepare_v2(database->connection, PROFILE_SQL, -1, &statement, NULL);
if (rc != SQLITE_OK ||
sqlite3_bind_text(statement, 1, source_exercise_id, -1, SQLITE_TRANSIENT) != SQLITE_OK ||
sqlite3_bind_text(statement, 2, canonical_exercise_id, -1, SQLITE_TRANSIENT) != SQLITE_OK) goto rollback;
rc = sqlite3_step(statement);
if (rc == SQLITE_DONE) { status = TRAINLOG_STATUS_NOT_FOUND; goto rollback; }
if (rc != SQLITE_ROW) goto rollback;
source_row_id = sqlite3_column_int64(statement, 0);
canonical_row_id = sqlite3_column_int64(statement, 1);
if (strcmp((const char *)sqlite3_column_text(statement, 2),
(const char *)sqlite3_column_text(statement, 3)) != 0 ||
strcmp((const char *)sqlite3_column_text(statement, 4),
(const char *)sqlite3_column_text(statement, 5)) != 0 ||
sqlite3_column_int64(statement, 6) != sqlite3_column_int64(statement, 7)) {
status = TRAINLOG_STATUS_CONFLICT; goto rollback;
}
if (sqlite3_column_type(statement, 8) != SQLITE_NULL)
source_primary = (const char *)sqlite3_column_text(statement, 8);
if (sqlite3_column_type(statement, 9) != SQLITE_NULL)
canonical_primary = (const char *)sqlite3_column_text(statement, 9);
if (source_primary != NULL && canonical_primary != NULL &&
strcmp(source_primary, canonical_primary) != 0) {
status = TRAINLOG_STATUS_CONFLICT; goto rollback;
}
if (canonical_primary != NULL || source_primary != NULL)
(void)snprintf(primary, sizeof(primary), "%s",
canonical_primary != NULL ? canonical_primary : source_primary);
if (sqlite3_finalize(statement) != SQLITE_OK) { statement = NULL; goto rollback; }
statement = NULL;
/* INVARIANT: promote the chosen primary before unioning secondaries, so a
* former secondary cannot mask it through the (exercise,zone) key. */
if (primary[0] != '\0') {
status = merge_bind_ids(database,
"DELETE FROM exercise_body_zones WHERE exercise_row_id=?2 "
"AND zone_id=(SELECT zone_id FROM exercise_body_zones "
"WHERE exercise_row_id=?1 AND role='primary');",
source_row_id, canonical_row_id);
if (status != TRAINLOG_STATUS_OK) goto rollback;
rc = sqlite3_prepare_v2(database->connection,
"INSERT OR REPLACE INTO exercise_body_zones(exercise_row_id,zone_id,role) "
"VALUES(?1,?2,'primary');", -1, &statement, NULL);
if (rc != SQLITE_OK || sqlite3_bind_int64(statement, 1, canonical_row_id) != SQLITE_OK ||
sqlite3_bind_text(statement, 2, primary, -1, SQLITE_TRANSIENT) != SQLITE_OK ||
sqlite3_step(statement) != SQLITE_DONE || sqlite3_finalize(statement) != SQLITE_OK) {
statement = NULL; goto rollback;
}
statement = NULL;
}
status = merge_bind_ids(database,
"INSERT OR IGNORE INTO exercise_body_zones(exercise_row_id,zone_id,role) "
"SELECT ?2,zone_id,'secondary' FROM exercise_body_zones "
"WHERE exercise_row_id=?1 AND role='secondary' "
"AND zone_id<>(SELECT COALESCE((SELECT zone_id FROM exercise_body_zones "
"WHERE exercise_row_id=?2 AND role='primary'),''));",
source_row_id, canonical_row_id);
if (status != TRAINLOG_STATUS_OK) goto rollback;
status = merge_bind_ids(database,
"UPDATE session_exercises SET exercise_row_id=?2 WHERE exercise_row_id=?1;",
source_row_id, canonical_row_id);
if (status != TRAINLOG_STATUS_OK) goto rollback;
rc = sqlite3_prepare_v2(database->connection,
"UPDATE exercise_aliases SET canonical_exercise_id=?2 "
"WHERE canonical_exercise_id=?1;", -1, &statement, NULL);
if (rc != SQLITE_OK ||
sqlite3_bind_text(statement, 1, source_exercise_id, -1, SQLITE_TRANSIENT) != SQLITE_OK ||
sqlite3_bind_text(statement, 2, canonical_exercise_id, -1, SQLITE_TRANSIENT) != SQLITE_OK ||
sqlite3_step(statement) != SQLITE_DONE || sqlite3_finalize(statement) != SQLITE_OK) {
statement = NULL; goto rollback;
}
statement = NULL;
rc = sqlite3_prepare_v2(database->connection,
"INSERT INTO exercise_aliases(source_exercise_id,canonical_exercise_id) "
"VALUES(?1,?2);", -1, &statement, NULL);
if (rc != SQLITE_OK ||
sqlite3_bind_text(statement, 1, source_exercise_id, -1, SQLITE_TRANSIENT) != SQLITE_OK ||
sqlite3_bind_text(statement, 2, canonical_exercise_id, -1, SQLITE_TRANSIENT) != SQLITE_OK ||
sqlite3_step(statement) != SQLITE_DONE || sqlite3_finalize(statement) != SQLITE_OK) {
statement = NULL; goto rollback;
}
statement = NULL;
status = merge_bind_ids(database,
"DELETE FROM exercise_body_zone_sync WHERE exercise_row_id IN(?1,?2);",
source_row_id, canonical_row_id);
if (status != TRAINLOG_STATUS_OK) goto rollback;
status = merge_bind_ids(database, "DELETE FROM exercises WHERE id=?1;",
source_row_id, canonical_row_id);
if (status != TRAINLOG_STATUS_OK) goto rollback;
if (execute_sql(database, "COMMIT;") != TRAINLOG_STATUS_OK) goto rollback;
return TRAINLOG_STATUS_OK;
rollback:
if (statement != NULL) (void)sqlite3_finalize(statement);
(void)sqlite3_exec(database->connection, "ROLLBACK;", NULL, NULL, NULL);
return status;
}
TrainlogStatus trainlog_database_preview_exercise_merge(
TrainlogDatabase *database,
const char *source_exercise_id,
TrainlogExerciseMergePreview *output
)
{
static const char *const SQL =
"SELECT "
"(SELECT COUNT(*) FROM session_exercises se WHERE se.exercise_row_id=e.id),"
"(SELECT COUNT(*) FROM performed_sets ps JOIN session_exercises se "
"ON se.id=ps.session_exercise_row_id WHERE se.exercise_row_id=e.id),"
"(SELECT COUNT(*) FROM continuous_activity ca JOIN session_exercises se "
"ON se.id=ca.session_exercise_row_id WHERE se.exercise_row_id=e.id),"
"(SELECT COUNT(*) FROM max_results mr JOIN session_exercises se "
"ON se.id=mr.session_exercise_row_id WHERE se.exercise_row_id=e.id),"
"(SELECT COUNT(DISTINCT se.equipment_id) FROM session_exercises se "
"WHERE se.exercise_row_id=e.id AND se.equipment_id IS NOT NULL "
"AND se.equipment_id<>''),"
"(SELECT COUNT(*) FROM exercise_body_zones z WHERE z.exercise_row_id=e.id) "
"FROM exercises e WHERE e.exercise_id=?1;";
sqlite3_stmt *statement = NULL;
TrainlogExerciseMergePreview preview = {0};
int rc;
if (database == NULL || database->connection == NULL ||
source_exercise_id == NULL || source_exercise_id[0] == '\0' ||
output == NULL) return TRAINLOG_STATUS_INVALID_ARGUMENT;
rc = sqlite3_prepare_v2(database->connection, SQL, -1, &statement, NULL);
if (rc != SQLITE_OK || sqlite3_bind_text(statement, 1, source_exercise_id,
-1, SQLITE_TRANSIENT) != SQLITE_OK) {
if (statement != NULL) (void)sqlite3_finalize(statement);
return TRAINLOG_STATUS_DATABASE_ERROR;
}
rc = sqlite3_step(statement);
if (rc == SQLITE_DONE) {
(void)sqlite3_finalize(statement);
return TRAINLOG_STATUS_NOT_FOUND;
}
if (rc != SQLITE_ROW) {
(void)sqlite3_finalize(statement);
return TRAINLOG_STATUS_DATABASE_ERROR;
}
preview.occurrences = (size_t)sqlite3_column_int64(statement, 0);
preview.performed_sets = (size_t)sqlite3_column_int64(statement, 1);
preview.continuous_activities = (size_t)sqlite3_column_int64(statement, 2);
preview.max_results = (size_t)sqlite3_column_int64(statement, 3);
preview.associated_equipment = (size_t)sqlite3_column_int64(statement, 4);
preview.body_zones = (size_t)sqlite3_column_int64(statement, 5);
if (sqlite3_finalize(statement) != SQLITE_OK)
return TRAINLOG_STATUS_DATABASE_ERROR;
*output = preview;
return TRAINLOG_STATUS_OK;
}
static TrainlogStatus lookup_exercise_row_id(
TrainlogDatabase *database,
const char *exercise_id,
@ -6664,14 +7007,16 @@ TrainlogStatus trainlog_database_list_occurrence_sets_page(
*output_count=count; return TRAINLOG_STATUS_OK;
}
TrainlogStatus trainlog_database_latest_explicit_max_context(
TrainlogDatabase *database, const char *exercise_id, TrainlogLatestExplicitMax *output)
static TrainlogStatus database_latest_explicit_max_context(
TrainlogDatabase *database, const char *exercise_id, const char *equipment_id,
TrainlogLatestExplicitMax *output)
{
static const char *const SCAN_SQL =
"SELECT se.id,s.session_id,se.entry_id,s.started_at FROM max_results mr "
"JOIN session_exercises se ON se.id=mr.session_exercise_row_id "
"JOIN sessions s ON s.id=se.session_row_id JOIN exercises e ON e.id=se.exercise_row_id "
"WHERE e.exercise_id=?1 AND s.session_type='max_test';";
"WHERE e.exercise_id=?1 AND s.session_type='max_test' "
"AND (?2 IS NULL OR se.equipment_id=?2);";
static const char *const HYDRATE_SQL =
"SELECT s.session_id,se.entry_id,s.started_at,COALESCE(se.equipment_id,''),se.load_mode,mr.max_weight_kg "
"FROM max_results mr JOIN session_exercises se ON se.id=mr.session_exercise_row_id "
@ -6693,6 +7038,8 @@ TrainlogStatus trainlog_database_latest_explicit_max_context(
if(status!=TRAINLOG_STATUS_OK)goto done;
rc=sqlite3_prepare_v2(database->connection,SCAN_SQL,-1,&statement,NULL);
if(rc==SQLITE_OK)rc=sqlite3_bind_text(statement,1,exercise_id,-1,SQLITE_TRANSIENT);
if(rc==SQLITE_OK)rc=equipment_id==NULL ? sqlite3_bind_null(statement,2)
: sqlite3_bind_text(statement,2,equipment_id,-1,SQLITE_TRANSIENT);
if(rc!=SQLITE_OK){status=TRAINLOG_STATUS_DATABASE_ERROR;goto done;}
while((rc=sqlite3_step(statement))==SQLITE_ROW){
KnowledgeTemporalCandidate candidate;
@ -6733,3 +7080,18 @@ done:
}
return status;
}
TrainlogStatus trainlog_database_latest_explicit_max_context(
TrainlogDatabase *database, const char *exercise_id, TrainlogLatestExplicitMax *output)
{
return database_latest_explicit_max_context(database, exercise_id, NULL, output);
}
TrainlogStatus trainlog_database_latest_explicit_max_equipment_context(
TrainlogDatabase *database, const char *exercise_id, const char *equipment_id,
TrainlogLatestExplicitMax *output)
{
if (equipment_id == NULL || equipment_id[0] == '\0')
return TRAINLOG_STATUS_INVALID_ARGUMENT;
return database_latest_explicit_max_context(database, exercise_id, equipment_id, output);
}

View file

@ -203,3 +203,32 @@ TrainlogStatus trainlog_measured_max_working_load(
return TRAINLOG_STATUS_OK;
}
TrainlogStatus trainlog_measured_max_target_load(
const TrainlogLatestExplicitMax *latest,
const char *equipment_id,
TrainlogLoadMode equipment_load_semantics,
int percent,
double *output_kg
)
{
double result;
/* WHY: %MAX is an arithmetic shortcut explicitly chosen by the user, not
* a training recommendation. CONTRACT: only the newest explicit MAX for
* this exercise and exact equipment context may cross into the calculator;
* assistance and missing/unknown contexts are rejected. */
if (latest == NULL || equipment_id == NULL || output_kg == NULL ||
!latest->found || equipment_id[0] == '\0' ||
strcmp(latest->equipment_id, equipment_id) != 0 ||
equipment_load_semantics != TRAINLOG_LOAD_EXTERNAL ||
!isfinite(latest->max_weight_kg) || latest->max_weight_kg <= 0.0 ||
percent < 1 || percent > 100)
return TRAINLOG_STATUS_INVALID_ARGUMENT;
result = latest->max_weight_kg * (double)percent / 100.0;
if (!isfinite(result) || result <= 0.0)
return TRAINLOG_STATUS_INVALID_ARGUMENT;
/* INVARIANT: callers persist only this resulting target weight. The MAX
* identity and selected percentage remain transient UI state. */
*output_kg = result;
return TRAINLOG_STATUS_OK;
}

View file

@ -55,6 +55,9 @@ static const char *const PC_EQUIPMENT_DEFINITIONS_NAME =
static const char *const EXERCISE_BODY_ZONES_NAME =
"trainlog-exercise-body-zones-v1.json";
static const char *const EXERCISE_ALIASES_NAME =
"trainlog-exercise-aliases-v1.json";
static const char *const SYNC_REQUEST_NAME =
"trainlog-sync-request-v1.json";
@ -85,9 +88,15 @@ static const char *const EQUIPMENT_DEFINITIONS_RESULT =
static const char *const EXERCISE_BODY_ZONES_LOCAL =
"/tmp/trainlog-exercise-body-zones-v1.json";
static const char *const EXERCISE_ALIASES_LOCAL =
"/tmp/trainlog-exercise-aliases-v1.json";
static const char *const EXERCISE_BODY_ZONES_RESULT =
"/tmp/trainlog-exercise-body-zones-result.txt";
static const char *const EXERCISE_ALIASES_RESULT =
"/tmp/trainlog-exercise-aliases-result.txt";
static const char *const SYNC_REQUEST_LOCAL =
"/tmp/trainlog-sync-request-v1.json";
@ -1222,6 +1231,7 @@ static TrainlogStatus sync_run_python_tool(
const char *tool_name,
const char *argument,
const char *database_path,
const char *mobile_export_path,
const char *result_path,
char *output,
size_t output_size
@ -1240,7 +1250,8 @@ static TrainlogStatus sync_run_python_tool(
argument == NULL ||
result_path == NULL ||
output == NULL ||
output_size < 2U
output_size < 2U ||
(mobile_export_path != NULL && database_path == NULL)
) {
return
TRAINLOG_STATUS_INVALID_ARGUMENT;
@ -1303,7 +1314,22 @@ static TrainlogStatus sync_run_python_tool(
result_fd
);
if (database_path != NULL) {
if (database_path != NULL && mobile_export_path != NULL) {
/* CONTRACT: the body-zone importer may consume the exact retained
* V2 snapshot as identity-reconciliation proof. argv stays
* bounded and shell-free; all other helpers omit this pair. */
execlp(
"python3",
"python3",
tool,
argument,
"--database",
database_path,
"--mobile-export",
mobile_export_path,
(char *)NULL
);
} else if (database_path != NULL) {
/* CONTRACT: helpers which mutate the desktop store receive its
* explicit XDG-resolved path. They must never infer a different
* user's database from Python's process environment. */
@ -2618,6 +2644,7 @@ TrainlogStatus trainlog_sync_run(
bool run_started = false;
bool receipt_published = false;
bool mobile_export_has_companions = false;
bool retained_mobile_export_is_v2 = false;
if (output == NULL || direction < TRAINLOG_SYNC_ANDROID_TO_PC ||
direction > TRAINLOG_SYNC_BIDIRECTIONAL ||
@ -2843,6 +2870,31 @@ TrainlogStatus trainlog_sync_run(
goto outbound;
}
/* WHY: aliases must exist before the frozen session/catalog snapshot is
* interpreted, so a retired source ID resolves without changing V3. */
status = sync_receive_current_android_artifact(&device, folder_id,
EXERCISE_ALIASES_NAME, EXERCISE_ALIASES_LOCAL, &ignored_size);
if (status == TRAINLOG_STATUS_OK) {
status = sync_run_python_tool("import_exercise_aliases.py",
EXERCISE_ALIASES_LOCAL, database_path, NULL, EXERCISE_ALIASES_RESULT,
tool_output, sizeof(tool_output));
if (status != TRAINLOG_STATUS_OK ||
strstr(tool_output, "EXERCISE_ALIAS_IMPORT=PASS") == NULL) {
char useful[TRAINLOG_SYNC_ERROR_MAX + 1U];
sync_last_nonempty_line(tool_output, useful, sizeof(useful));
sync_compose_diagnostic(output->error, sizeof(output->error),
"Android→PC : alias exercices : ",
useful[0] != '\0' ? useful : "import échoué");
final_status = TRAINLOG_STATUS_DATABASE_ERROR;
goto finalize;
}
} else if (status != TRAINLOG_STATUS_NOT_FOUND) {
(void)snprintf(output->error, sizeof(output->error),
"Android→PC : lecture alias exercices échouée.");
final_status = status;
goto finalize;
}
/* Definitions must reconcile before either V3/V2 reference artifact. A
* missing file is accepted only for historic snapshots with no custom ID. */
status = sync_receive_current_android_artifact(
@ -2856,6 +2908,7 @@ TrainlogStatus trainlog_sync_run(
status = sync_run_python_tool("import_equipment_definitions.py",
MOBILE_EQUIPMENT_DEFINITIONS_LOCAL,
database_path,
NULL,
EQUIPMENT_DEFINITIONS_RESULT,
tool_output, sizeof(tool_output));
if (status != TRAINLOG_STATUS_OK ||
@ -2895,6 +2948,7 @@ TrainlogStatus trainlog_sync_run(
status = sync_receive_current_android_artifact(
&device, folder_id, MOBILE_EXPORT_V2_NAME,
MOBILE_EXPORT_LOCAL, &ignored_size);
retained_mobile_export_is_v2 = status == TRAINLOG_STATUS_OK;
}
mobile_export_has_companions = status == TRAINLOG_STATUS_OK;
@ -2919,6 +2973,7 @@ TrainlogStatus trainlog_sync_run(
"import_mobile_export.py",
MOBILE_EXPORT_LOCAL,
database_path,
NULL,
MOBILE_IMPORT_RESULT,
tool_output,
sizeof(tool_output)
@ -2972,7 +3027,9 @@ TrainlogStatus trainlog_sync_run(
: TRAINLOG_STATUS_NOT_FOUND;
if (status == TRAINLOG_STATUS_OK) {
status = sync_run_python_tool("import_exercise_body_zones.py",
EXERCISE_BODY_ZONES_LOCAL, database_path, EXERCISE_BODY_ZONES_RESULT,
EXERCISE_BODY_ZONES_LOCAL, database_path,
retained_mobile_export_is_v2 ? MOBILE_EXPORT_LOCAL : NULL,
EXERCISE_BODY_ZONES_RESULT,
tool_output, sizeof(tool_output));
if (status != TRAINLOG_STATUS_OK ||
strstr(tool_output, "EXERCISE_BODY_ZONES_IMPORT=PASS") == NULL) {
@ -3013,6 +3070,7 @@ TrainlogStatus trainlog_sync_run(
status = sync_run_python_tool("import_equipment_associations.py",
EQUIPMENT_ASSOCIATIONS_LOCAL,
database_path,
NULL,
EQUIPMENT_ASSOCIATIONS_RESULT,
tool_output, sizeof(tool_output));
if (status != TRAINLOG_STATUS_OK ||
@ -3043,9 +3101,29 @@ TrainlogStatus trainlog_sync_run(
}
outbound:
status = sync_run_python_tool("export_exercise_aliases.py",
EXERCISE_ALIASES_LOCAL, database_path, NULL, EXERCISE_ALIASES_RESULT,
tool_output, sizeof(tool_output));
if (status != TRAINLOG_STATUS_OK ||
strstr(tool_output, "EXERCISE_ALIAS_EXPORT=PASS") == NULL) {
(void)snprintf(output->error, sizeof(output->error),
"PC→Android : export alias exercices échoué.");
final_status = TRAINLOG_STATUS_SYSTEM_ERROR;
goto finalize;
}
status = sync_publish_named(&device, folder_id, EXERCISE_ALIASES_LOCAL,
EXERCISE_ALIASES_NAME);
if (status != TRAINLOG_STATUS_OK) {
(void)snprintf(output->error, sizeof(output->error),
"PC→Android : publication alias exercices échouée.");
final_status = status;
goto finalize;
}
status = sync_run_python_tool("export_equipment_definitions.py",
PC_EQUIPMENT_DEFINITIONS_LOCAL,
database_path,
NULL,
EQUIPMENT_DEFINITIONS_RESULT,
tool_output, sizeof(tool_output));
if (status != TRAINLOG_STATUS_OK ||
@ -3070,6 +3148,7 @@ outbound:
"export_pc_catalog.py",
PC_CATALOG_LOCAL,
database_path,
NULL,
PC_CATALOG_RESULT,
tool_output,
sizeof(tool_output)
@ -3122,7 +3201,8 @@ outbound:
}
status = sync_run_python_tool("export_exercise_body_zones.py",
EXERCISE_BODY_ZONES_LOCAL, database_path, EXERCISE_BODY_ZONES_RESULT,
EXERCISE_BODY_ZONES_LOCAL, database_path, NULL,
EXERCISE_BODY_ZONES_RESULT,
tool_output, sizeof(tool_output));
if (status != TRAINLOG_STATUS_OK ||
strstr(tool_output, "EXERCISE_BODY_ZONES_EXPORT=PASS") == NULL) {
@ -3144,7 +3224,8 @@ outbound:
* acknowledge that snapshot as its baseline. Reusing the strict importer
* records equal state and can never union secondary zones. */
status = sync_run_python_tool("import_exercise_body_zones.py",
EXERCISE_BODY_ZONES_LOCAL, database_path, EXERCISE_BODY_ZONES_RESULT,
EXERCISE_BODY_ZONES_LOCAL, database_path, NULL,
EXERCISE_BODY_ZONES_RESULT,
tool_output, sizeof(tool_output));
if (status != TRAINLOG_STATUS_OK ||
strstr(tool_output, "EXERCISE_BODY_ZONES_IMPORT=PASS") == NULL) {
@ -3159,6 +3240,7 @@ outbound:
status = sync_run_python_tool("export_pc_mobile.py", PC_MOBILE_EXPORT_LOCAL,
database_path,
NULL,
PC_CATALOG_RESULT, tool_output, sizeof(tool_output));
if (status != TRAINLOG_STATUS_OK || strstr(tool_output, "PC_MOBILE_EXPORT=PASS") == NULL) {
(void)snprintf(output->error, sizeof(output->error), "PC→Android : export séances V3 échoué.");
@ -3175,6 +3257,7 @@ outbound:
status = sync_run_python_tool("export_equipment_associations.py",
EQUIPMENT_ASSOCIATIONS_LOCAL,
database_path,
NULL,
EQUIPMENT_ASSOCIATIONS_RESULT,
tool_output, sizeof(tool_output));
if (status != TRAINLOG_STATUS_OK ||

View file

@ -47,11 +47,10 @@ TrainlogSyncScreenAction trainlog_sync_screen_dispatch(
direction = state->direction;
if (key == 'a' || key == 'A') {
direction = TRAINLOG_SYNC_ANDROID_TO_PC;
} else if (key == 'p' || key == 'P') {
direction = TRAINLOG_SYNC_PC_TO_ANDROID;
} else if (key == 'b' || key == 'B') {
/* CONTRACT: the desktop product exposes one sync operation. Directional
* engine modes remain an internal compatibility capability for receipts
* and existing callers, but the TUI always requests the complete exchange. */
if (key == 's' || key == 'S') {
direction = TRAINLOG_SYNC_BIDIRECTIONAL;
} else if (state->confirming) {
if (key == '\n' || key == TRAINLOG_KEY_ENTER) {
@ -87,7 +86,6 @@ TrainlogSyncScreenAction trainlog_sync_screen_dispatch(
state->direction
);
} else {
/* The former s shortcut is deliberately not an action. */
return sync_screen_action(
TRAINLOG_SYNC_SCREEN_NONE,
state->direction
@ -160,8 +158,8 @@ const char *trainlog_sync_screen_footer(
)
{
if (terminal_columns >= 100) {
return "a Android→PC p PC→Android b PC↔Android r actualiser Échap retour";
return "s Synchroniser maintenant (PC↔Android) r Actualiser appareil Échap retour";
}
return "a A→PC p PC→A b A↔PC r act. Échap retour";
return "s Synchroniser PC↔Android r Actualiser Échap retour";
}

View file

@ -11,6 +11,7 @@
#include <stdlib.h>
#include <notcurses/notcurses.h>
#include <utf8proc.h>
struct TrainlogTerminal {
struct notcurses *notcurses;
@ -27,11 +28,40 @@ struct TrainlogPanel {
int left;
};
struct TrainlogSurface {
TrainlogTerminal *terminal;
struct ncplane *plane;
int height;
int width;
};
static unsigned role_rgb(TrainlogColorRole role)
{
switch (role) {
case TRAINLOG_COLOR_ACCENT: return TRAINLOG_RGB_LAVENDER;
case TRAINLOG_COLOR_SUCCESS: return TRAINLOG_RGB_SUCCESS;
case TRAINLOG_COLOR_WARNING: return TRAINLOG_RGB_WARNING;
case TRAINLOG_COLOR_ERROR: return TRAINLOG_RGB_ERROR;
case TRAINLOG_COLOR_MUTED: return TRAINLOG_RGB_SUBTEXT;
case TRAINLOG_COLOR_GRAPH: return 0xf5c2e7U;
case TRAINLOG_COLOR_INFO: return TRAINLOG_RGB_INFO;
case TRAINLOG_COLOR_NOTICE: return TRAINLOG_RGB_NOTICE;
case TRAINLOG_COLOR_DEFAULT:
default: return TRAINLOG_RGB_TEXT;
}
}
static void plane_set_rgb(struct ncplane *plane, bool foreground, unsigned rgb)
{
unsigned red = (rgb >> 16U) & 0xffU;
unsigned green = (rgb >> 8U) & 0xffU;
unsigned blue = rgb & 0xffU;
if (foreground) (void)ncplane_set_fg_rgb8(plane, red, green, blue);
else (void)ncplane_set_bg_rgb8(plane, red, green, blue);
}
static void terminal_apply_style(TrainlogTerminal *terminal)
{
unsigned red = 205U;
unsigned green = 214U;
unsigned blue = 244U;
TrainlogColorRole role;
if (terminal == NULL || terminal->plane == NULL) {
@ -39,24 +69,14 @@ static void terminal_apply_style(TrainlogTerminal *terminal)
}
role = (TrainlogColorRole)((terminal->style >> 8U) & 0xffU);
switch (role) {
case TRAINLOG_COLOR_ACCENT: red = 148U; green = 226U; blue = 213U; break;
case TRAINLOG_COLOR_SUCCESS: red = 166U; green = 227U; blue = 161U; break;
case TRAINLOG_COLOR_WARNING: red = 249U; green = 226U; blue = 175U; break;
case TRAINLOG_COLOR_ERROR: red = 243U; green = 139U; blue = 168U; break;
case TRAINLOG_COLOR_MUTED: red = 137U; green = 180U; blue = 250U; break;
case TRAINLOG_COLOR_GRAPH: red = 245U; green = 194U; blue = 231U; break;
case TRAINLOG_COLOR_DEFAULT:
default: break;
}
(void)ncplane_set_fg_rgb8(terminal->plane, red, green, blue);
plane_set_rgb(terminal->plane, true, role_rgb(role));
/* CONTRACT: selection remains visible without relying only on foreground
* color. A role-aware surface fill survives terminals with weak color
* contrast while ordinary drawing uses the canonical dark background. */
if ((terminal->style & TRAINLOG_TEXT_REVERSE) != 0U) {
(void)ncplane_set_bg_rgb8(terminal->plane, 49U, 50U, 68U);
plane_set_rgb(terminal->plane, false, TRAINLOG_RGB_SURFACE0);
} else {
(void)ncplane_set_bg_rgb8(terminal->plane, 30U, 30U, 46U);
plane_set_rgb(terminal->plane, false, TRAINLOG_RGB_BASE);
}
ncplane_set_styles(terminal->plane,
(terminal->style & TRAINLOG_TEXT_BOLD) != 0U ? NCSTYLE_BOLD : 0U);
@ -82,7 +102,7 @@ TrainlogTerminal *trainlog_terminal_create(void)
free(terminal);
return NULL;
}
(void)ncplane_set_bg_rgb8(terminal->plane, 30U, 30U, 46U);
plane_set_rgb(terminal->plane, false, TRAINLOG_RGB_BASE);
terminal->pushed_key = TRAINLOG_KEY_NONE;
terminal_apply_style(terminal);
return terminal;
@ -261,6 +281,10 @@ static int terminal_key(uint32_t id, bool shifted)
}
switch (id) {
/* WHY: Notcurses reports the physical Escape key as the Unicode control
* byte on common terminals. Normalize it here so overlays, forms and
* route guards consume one Trainlog-owned semantic key. */
case 27U: return TRAINLOG_KEY_ESCAPE;
case NCKEY_UP: return TRAINLOG_KEY_UP; case NCKEY_DOWN: return TRAINLOG_KEY_DOWN;
case NCKEY_LEFT: return TRAINLOG_KEY_LEFT; case NCKEY_RIGHT: return TRAINLOG_KEY_RIGHT;
case NCKEY_ENTER: return TRAINLOG_KEY_ENTER; case NCKEY_TAB: return TRAINLOG_KEY_TAB;
@ -270,7 +294,8 @@ static int terminal_key(uint32_t id, bool shifted)
case NCKEY_PGDOWN: return TRAINLOG_KEY_PAGE_DOWN; case NCKEY_RESIZE: return TRAINLOG_KEY_RESIZE;
case NCKEY_F01: return TRAINLOG_KEY_F1; case NCKEY_F02: return TRAINLOG_KEY_F2;
case NCKEY_F03: return TRAINLOG_KEY_F3; case NCKEY_F04: return TRAINLOG_KEY_F4;
case NCKEY_F05: return TRAINLOG_KEY_F5; default: return (int)id;
case NCKEY_F05: return TRAINLOG_KEY_F5; case NCKEY_F06: return TRAINLOG_KEY_F6;
case NCKEY_F07: return TRAINLOG_KEY_F7; default: return (int)id;
}
}
@ -362,6 +387,139 @@ bool trainlog_terminal_push_key(TrainlogTerminal *terminal, int key)
return true;
}
bool trainlog_terminal_refresh_geometry(TrainlogTerminal *terminal)
{
/* CONTRACT: NCKEY_RESIZE only reports that geometry changed. Notcurses
* must refresh its standard plane before shell rectangles are recomputed;
* otherwise a keyless resize can leave chrome at the former dimensions. */
return terminal != NULL && terminal->notcurses != NULL &&
notcurses_refresh(terminal->notcurses, NULL, NULL) == 0;
}
static bool surface_rect_valid(const TrainlogTerminal *terminal,
int top, int left, int height, int width)
{
return terminal != NULL && top >= 0 && left >= 0 && height > 0 && width > 0 &&
top <= trainlog_terminal_rows(terminal) - height &&
left <= trainlog_terminal_columns(terminal) - width;
}
TrainlogSurface *trainlog_surface_create(TrainlogTerminal *terminal,
const char *name,
int top, int left,
int height, int width)
{
ncplane_options options = {0};
TrainlogSurface *surface;
if (!surface_rect_valid(terminal, top, left, height, width)) return NULL;
surface = calloc(1U, sizeof(*surface));
if (surface == NULL) return NULL;
options.y = top; options.x = left;
options.rows = (unsigned)height; options.cols = (unsigned)width;
options.name = name;
surface->plane = ncplane_create(terminal->plane, &options);
if (surface->plane == NULL) { free(surface); return NULL; }
surface->terminal = terminal; surface->height = height; surface->width = width;
trainlog_surface_set_role(surface, TRAINLOG_COLOR_DEFAULT,
TRAINLOG_RGB_BASE, TRAINLOG_TEXT_NORMAL);
return surface;
}
bool trainlog_surface_set_rect(TrainlogSurface *surface,
int top, int left, int height, int width)
{
if (surface == NULL || surface->plane == NULL ||
!surface_rect_valid(surface->terminal, top, left, height, width)) return false;
if (ncplane_resize_simple(surface->plane, (unsigned)height,
(unsigned)width) != 0 ||
ncplane_move_yx(surface->plane, top, left) != 0) return false;
surface->height = height; surface->width = width;
return true;
}
void trainlog_surface_destroy(TrainlogSurface *surface)
{
if (surface == NULL) return;
if (surface->plane != NULL) (void)ncplane_destroy(surface->plane);
free(surface);
}
void trainlog_surface_erase(TrainlogSurface *surface)
{
if (surface != NULL && surface->plane != NULL) ncplane_erase(surface->plane);
}
void trainlog_surface_set_role(TrainlogSurface *surface,
TrainlogColorRole foreground,
unsigned background_rgb,
TrainlogTextStyle style)
{
if (surface == NULL || surface->plane == NULL) return;
plane_set_rgb(surface->plane, true, role_rgb(foreground));
plane_set_rgb(surface->plane, false, background_rgb);
ncplane_set_styles(surface->plane,
(style & TRAINLOG_TEXT_BOLD) != 0U ? NCSTYLE_BOLD : 0U);
(void)ncplane_set_base(surface->plane, " ", 0U, ncplane_channels(surface->plane));
}
void trainlog_surface_printf(TrainlogSurface *surface,
int row, int column,
const char *format, ...)
{
va_list arguments;
va_list copy;
char *value;
int count;
if (surface == NULL || surface->plane == NULL || format == NULL || row < 0 ||
column < 0 || row >= surface->height || column >= surface->width) return;
va_start(arguments, format); va_copy(copy, arguments);
count = vsnprintf(NULL, 0U, format, copy); va_end(copy);
if (count < 0) { va_end(arguments); return; }
value = malloc((size_t)count + 1U);
if (value != NULL) {
size_t bytes = 0U;
int cells = 0;
int available = surface->width - column;
(void)vsnprintf(value, (size_t)count + 1U, format, arguments);
/* INVARIANT: child-plane clipping is not inherited. Bound all output
* to complete UTF-8 code points and cell width so content cannot paint
* the footer or leave an invalid trailing byte sequence. */
while (value[bytes] != '\0') {
utf8proc_int32_t codepoint;
utf8proc_ssize_t parsed = utf8proc_iterate(
(const utf8proc_uint8_t *)value + bytes, -1, &codepoint);
int width;
if (parsed <= 0) break;
width = utf8proc_charwidth(codepoint);
if (width < 0) width = 1;
if (cells + width > available) break;
cells += width; bytes += (size_t)parsed;
}
(void)ncplane_putnstr_yx(surface->plane, row, column,
bytes, value);
free(value);
}
va_end(arguments);
}
void trainlog_surface_draw(TrainlogSurface *surface,
int row, int column, uint32_t codepoint)
{
nccell cell = NCCELL_TRIVIAL_INITIALIZER;
if (surface == NULL || surface->plane == NULL || row < 0 || column < 0 ||
row >= surface->height || column >= surface->width) return;
if (nccell_load_ucs32(surface->plane, &cell, codepoint) >= 0) {
(void)ncplane_putc_yx(surface->plane, row, column, &cell);
nccell_release(surface->plane, &cell);
}
}
void trainlog_surface_move_top(TrainlogSurface *surface)
{
if (surface != NULL && surface->plane != NULL)
(void)ncplane_move_family_top(surface->plane);
}
TrainlogPanel *tui_panel_create(TrainlogTerminal *terminal, int height, int width,
int top, int left)
{

File diff suppressed because it is too large Load diff

222
tui/tests/test_app_shell.c Normal file
View file

@ -0,0 +1,222 @@
/**
* @file test_app_shell.c
* @brief APP_SHELL_V1 state and geometry contract regressions.
*/
#include <stdio.h>
#include <string.h>
#include "trainlog/app_shell.h"
#include "trainlog/terminal.h"
#define CHECK(value) do { if (!(value)) { \
(void)fprintf(stderr, "CHECK failed at %s:%d: %s\n", \
__FILE__, __LINE__, #value); return false; } } while (0)
static bool test_geometry(void)
{
static const int sizes[][2] = {
{120, 35}, {120, 31}, {100, 30}, {100, 25}, {80, 24}, {72, 20}
};
size_t index;
for (index = 0U; index < sizeof(sizes) / sizeof(sizes[0]); ++index) {
TrainlogShellLayout layout;
TrainlogRect overlay;
trainlog_shell_layout_compute(sizes[index][0], sizes[index][1], &layout);
CHECK(layout.usable);
CHECK(layout.header.y == 0 && layout.header.height == 2);
CHECK(layout.content.y == 2 && layout.content.height == sizes[index][1] - 4);
CHECK(layout.footer.y == sizes[index][1] - 2 && layout.footer.height == 2);
CHECK(layout.content.y + layout.content.height == layout.footer.y);
CHECK(layout.sidebar_visible ==
(sizes[index][0] >= 100 && sizes[index][1] >= 26));
CHECK(layout.sidebar_expanded ==
(sizes[index][0] >= 120 && sizes[index][1] >= 32));
if (layout.sidebar_visible) {
CHECK(layout.sidebar.width == 22 && layout.separator.x == 22);
CHECK(layout.content.x == 23);
} else CHECK(layout.content.x == 0);
overlay = trainlog_shell_overlay_rect(&layout, 80, 29);
CHECK(trainlog_shell_rect_contains(&(TrainlogRect){2, 0,
sizes[index][1] - 4, sizes[index][0]}, &overlay));
CHECK(overlay.y + overlay.height <= layout.footer.y);
}
{
TrainlogShellLayout layout;
trainlog_shell_layout_compute(99, 40, &layout);
CHECK(!layout.sidebar_visible);
trainlog_shell_layout_compute(160, 25, &layout);
CHECK(!layout.sidebar_visible);
trainlog_shell_layout_compute(71, 20, &layout);
CHECK(!layout.usable);
}
return true;
}
static bool test_navigation_and_actions(void)
{
TrainlogNavigationState navigation;
TrainlogActionModel actions;
TrainlogAction action = {"open.exercises", '3', "Exercices", true, 20U,
TRAINLOG_INTENT_OPEN_ROUTE, TRAINLOG_ROUTE_EXERCISES};
const TrainlogAction *found;
trainlog_navigation_init(&navigation);
CHECK(trainlog_navigation_open(&navigation, TRAINLOG_ROUTE_EXERCISES,
"ex_11111111-1111-4111-8111-111111111111"));
CHECK(navigation.current.route == TRAINLOG_ROUTE_EXERCISES);
CHECK(trainlog_navigation_open(&navigation, TRAINLOG_ROUTE_STATS_EXERCISE,
navigation.current.stable_id));
CHECK(trainlog_navigation_back(&navigation));
CHECK(navigation.current.route == TRAINLOG_ROUTE_EXERCISES);
CHECK(strcmp(navigation.current.stable_id,
"ex_11111111-1111-4111-8111-111111111111") == 0);
trainlog_actions_clear(&actions);
CHECK(trainlog_actions_add(&actions, &action));
CHECK(!trainlog_actions_add(&actions, &action));
CHECK(actions.count == 1U);
action.identifier = "help"; action.key = '?'; action.label = "Aide";
action.priority = 10U; action.intent = TRAINLOG_INTENT_OPEN_HELP;
CHECK(trainlog_actions_add(&actions, &action));
found = trainlog_actions_find_key(&actions, '3');
CHECK(found != NULL && found->intent == TRAINLOG_INTENT_OPEN_ROUTE);
CHECK(strcmp(trainlog_actions_at_priority(&actions, 0U)->identifier, "help") == 0);
actions.items[0].enabled = false;
CHECK(trainlog_actions_find_key(&actions, '3') == NULL);
return true;
}
static bool test_overlay_isolation_and_focus(void)
{
TrainlogOverlayStack stack;
TrainlogFocusTarget focus = TRAINLOG_FOCUS_NAVIGATION;
char selected[TRAINLOG_SHELL_STABLE_ID_CAPACITY];
trainlog_overlays_init(&stack);
CHECK(trainlog_overlays_push(&stack, TRAINLOG_OVERLAY_NAVIGATION,
TRAINLOG_FOCUS_CONTENT, "se_1"));
CHECK(trainlog_overlays_push(&stack, TRAINLOG_OVERLAY_CONFIRMATION,
TRAINLOG_FOCUS_OVERLAY, "se_1"));
CHECK(trainlog_overlays_push(&stack, TRAINLOG_OVERLAY_HELP,
TRAINLOG_FOCUS_OVERLAY, "se_1"));
CHECK(!trainlog_overlays_push(&stack, TRAINLOG_OVERLAY_HELP,
TRAINLOG_FOCUS_OVERLAY, "se_1"));
CHECK(trainlog_overlays_top(&stack)->type == TRAINLOG_OVERLAY_HELP);
CHECK(trainlog_overlays_pop(&stack, &focus, selected));
CHECK(focus == TRAINLOG_FOCUS_OVERLAY && strcmp(selected, "se_1") == 0);
CHECK(trainlog_overlays_top(&stack)->type == TRAINLOG_OVERLAY_CONFIRMATION);
return true;
}
static bool test_search_utf8(void)
{
TrainlogSearchState search;
char fill[2] = {'a', '\0'};
size_t index;
trainlog_search_init(&search);
search.open = true; search.focused = true;
CHECK(trainlog_search_insert(&search, "é", strlen("é")));
CHECK(trainlog_search_insert(&search, "", strlen("")));
trainlog_search_home(&search); trainlog_search_right(&search);
CHECK(search.cursor == strlen("é"));
trainlog_search_left(&search); CHECK(search.cursor == 0U);
trainlog_search_end(&search);
CHECK(trainlog_search_backspace(&search));
CHECK(strcmp(search.text, "é") == 0 && search.bytes == strlen("é"));
CHECK(!trainlog_search_escape(&search));
CHECK(search.open && search.focused && search.bytes == 0U);
CHECK(trainlog_search_escape(&search));
CHECK(!search.open && !search.focused);
trainlog_search_init(&search);
for (index = 0U; index < 200U; ++index)
CHECK(trainlog_search_insert(&search, fill, 1U));
CHECK(!trainlog_search_insert(&search, fill, 1U));
CHECK(search.capacity_error && search.bytes == 200U);
trainlog_search_init(&search);
CHECK(trainlog_search_insert(&search, "e", 1U));
CHECK(trainlog_search_insert(&search, "\xcc\x81", 2U));
CHECK(trainlog_search_insert(&search, "x", 1U));
trainlog_search_left(&search);
CHECK(search.cursor == 3U);
trainlog_search_left(&search);
CHECK(search.cursor == 0U);
trainlog_search_end(&search);
CHECK(trainlog_search_backspace(&search));
CHECK(strcmp(search.text, "e\xcc\x81") == 0);
CHECK(trainlog_search_backspace(&search));
CHECK(search.bytes == 0U && search.cursor == 0U);
return true;
}
static bool test_stable_selection_and_resize(void)
{
const char *first[] = {"a", "b", "c", "d", "e"};
const char *filtered[] = {"b", "d", "e"};
TrainlogListState list;
trainlog_list_init(&list);
trainlog_list_set_items(&list, first, 5U, 2U, false, true);
trainlog_list_move(&list, first, 3);
CHECK(strcmp(list.selected_id, "d") == 0 && list.viewport_start == 2U);
trainlog_list_set_items(&list, filtered, 3U, 1U, false, true);
CHECK(strcmp(list.selected_id, "d") == 0 && list.selected_index == 1U);
trainlog_list_set_items(&list, filtered, 3U, 3U, true, false);
CHECK(strcmp(list.selected_id, "d") == 0 && list.viewport_start == 0U);
return true;
}
static bool test_list_label_clipping(void)
{
char output[64];
trainlog_shell_format_list_label("Développé couché très long", 12,
output, sizeof(output));
CHECK(strcmp(output, "Développé c…") == 0);
trainlog_shell_format_list_label("Court", 12, output, sizeof(output));
CHECK(strcmp(output, "Court") == 0);
trainlog_shell_format_list_label("界界界", 5, output, sizeof(output));
CHECK(strcmp(output, "界界…") == 0);
return true;
}
static bool test_draft_and_generator_guards(void)
{
TrainlogDurabilityState state = {0};
CHECK(trainlog_shell_leave_decision(&state, false) == TRAINLOG_LEAVE_ALLOW);
state.session_draft = true; state.session_dirty = true;
CHECK(trainlog_shell_leave_decision(&state, false) == TRAINLOG_LEAVE_ALLOW);
CHECK(trainlog_shell_leave_decision(&state, true) == TRAINLOG_LEAVE_CONFIRM_DISCARD);
state.generator_preview = true; state.generator_preview_dirty = true;
CHECK(trainlog_shell_leave_decision(&state, false) == TRAINLOG_LEAVE_CONFIRM_KEEP);
trainlog_shell_discard_transient(&state);
CHECK(trainlog_shell_leave_decision(&state, true) == TRAINLOG_LEAVE_ALLOW);
return true;
}
static bool test_shared_form_adapter(void)
{
TrainlogFormField field;
trainlog_form_init(&field, "12");
CHECK(field.active && strcmp(field.text, "12") == 0);
CHECK(trainlog_form_handle(&field, TRAINLOG_KEY_HOME) ==
TRAINLOG_FORM_IGNORED);
CHECK(trainlog_form_handle(&field, 0x00e9) == TRAINLOG_FORM_EDITED);
CHECK(strcmp(field.text, "é12") == 0);
CHECK(trainlog_form_handle(&field, TRAINLOG_KEY_TAB) ==
TRAINLOG_FORM_NEXT);
CHECK(trainlog_form_handle(&field, TRAINLOG_KEY_F6) ==
TRAINLOG_FORM_OPEN_NAVIGATION);
CHECK(trainlog_form_handle(&field, TRAINLOG_KEY_F7) ==
TRAINLOG_FORM_OPEN_ACTIONS);
CHECK(trainlog_form_handle(&field, TRAINLOG_KEY_ESCAPE) ==
TRAINLOG_FORM_CANCEL);
return true;
}
int main(void)
{
if (!test_geometry() || !test_navigation_and_actions() ||
!test_overlay_isolation_and_focus() || !test_search_utf8() ||
!test_stable_selection_and_resize() ||
!test_list_label_clipping() ||
!test_draft_and_generator_guards() ||
!test_shared_form_adapter()) return 1;
(void)printf("PASS app_shell\n");
return 0;
}

View file

@ -193,7 +193,7 @@ static int migration_preserves_identity_and_history(void)
CHECK(sqlite3_close(raw) == SQLITE_OK);
raw = NULL;
CHECK(trainlog_database_open(path, &database) == TRAINLOG_STATUS_OK);
CHECK(trainlog_database_schema_version(database, &version) == TRAINLOG_STATUS_OK && version == 11);
CHECK(trainlog_database_schema_version(database, &version) == TRAINLOG_STATUS_OK && version == 12);
CHECK(trainlog_database_list_exercise_body_zones(database,
"ex_b432623f-bfe9-4daf-a653-60ec7fdffbde", relations, 4U, &count) == TRAINLOG_STATUS_OK);
CHECK(count == 2U);

View file

@ -4,6 +4,8 @@
*/
#include <stdbool.h>
#include <stdint.h>
#include <limits.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
@ -114,9 +116,148 @@ static bool test_custom_equipment_round_trip(void)
return true;
}
static bool insert_paged_equipment(
TrainlogDatabase *database, const char *equipment_id, const char *display_name)
{
TrainlogCustomEquipment equipment;
(void)memset(&equipment, 0, sizeof(equipment));
(void)snprintf(equipment.equipment_id, sizeof(equipment.equipment_id), "%s", equipment_id);
(void)snprintf(equipment.display_name, sizeof(equipment.display_name), "%s", display_name);
(void)snprintf(equipment.label_name, sizeof(equipment.label_name), "%s", display_name);
(void)snprintf(equipment.equipment_type, sizeof(equipment.equipment_type), "%s", "machine");
(void)snprintf(equipment.load_semantics, sizeof(equipment.load_semantics), "%s", "external");
return trainlog_database_create_custom_equipment(database, &equipment) == TRAINLOG_STATUS_OK;
}
static bool test_custom_equipment_page_corruption(void)
{
char path[] = "/tmp/trainlog-custom-equipment-page-XXXXXX";
TrainlogDatabase *database = NULL;
TrainlogCustomEquipment page[2];
sqlite3 *raw = NULL;
sqlite3_stmt *statement = NULL;
const unsigned char blob[] = { 'x', 'y' };
const char embedded_nul[] = { 'x', '\0', 'y' };
char oversized[TRAINLOG_NAME_MAX + 2U];
const void *invalid_values[] = { oversized, blob, embedded_nul };
const int invalid_lengths[] = { (int)sizeof(oversized), (int)sizeof(blob), (int)sizeof(embedded_nul) };
size_t count;
bool more;
size_t index;
int fd = mkstemp(path);
CHECK(fd >= 0);
CHECK(close(fd) == 0);
CHECK(trainlog_database_open(path, &database) == TRAINLOG_STATUS_OK);
CHECK(insert_paged_equipment(database, "eq_page_valid", "Alpha"));
CHECK(insert_paged_equipment(database, "eq_page_invalid", "Bravo"));
CHECK(sqlite3_open(path, &raw) == SQLITE_OK);
CHECK(sqlite3_prepare_v2(raw, "UPDATE custom_equipment SET label_name=?1 WHERE equipment_id='eq_page_invalid';",
-1, &statement, NULL) == SQLITE_OK);
(void)memset(oversized, 'x', sizeof(oversized));
/* label_name is NOT NULL, so NULL cannot reach the reader's defensive branch. */
for (index = 0U; index < 3U; ++index) {
if (index == 1U) {
CHECK(sqlite3_bind_blob(statement, 1, invalid_values[index], invalid_lengths[index], SQLITE_TRANSIENT) == SQLITE_OK);
} else {
CHECK(sqlite3_bind_text(statement, 1, (const char *)invalid_values[index],
invalid_lengths[index], SQLITE_TRANSIENT) == SQLITE_OK);
}
CHECK(sqlite3_step(statement) == SQLITE_DONE);
CHECK(sqlite3_reset(statement) == SQLITE_OK);
count = 9U;
more = true;
CHECK(trainlog_database_list_custom_equipment_page(database, 0U, page, 1U, &count, &more) == TRAINLOG_STATUS_DATABASE_ERROR);
CHECK(count == 0U && !more);
count = 9U;
more = true;
CHECK(trainlog_database_list_custom_equipment_page(database, 0U, page, 2U, &count, &more) == TRAINLOG_STATUS_DATABASE_ERROR);
CHECK(count == 0U && !more);
CHECK(sqlite3_bind_text(statement, 1, "Bravo", -1, SQLITE_STATIC) == SQLITE_OK);
CHECK(sqlite3_step(statement) == SQLITE_DONE);
CHECK(sqlite3_reset(statement) == SQLITE_OK);
CHECK(trainlog_database_list_custom_equipment_page(database, 0U, page, 2U, &count, &more) == TRAINLOG_STATUS_OK);
CHECK(count == 2U && !more && strcmp(page[0].equipment_id, "eq_page_valid") == 0);
}
CHECK(sqlite3_finalize(statement) == SQLITE_OK);
CHECK(sqlite3_close(raw) == SQLITE_OK);
trainlog_database_close(database);
CHECK(unlink(path) == 0);
return true;
}
static bool test_custom_equipment_page_reader(void)
{
TrainlogDatabase *database = NULL;
TrainlogCustomEquipment page[TRAINLOG_CUSTOM_EQUIPMENT_PAGE_MAX];
char equipment_id[32];
char display_name[32];
size_t count = 99U;
bool more = true;
size_t index;
CHECK(trainlog_database_open(":memory:", &database) == TRAINLOG_STATUS_OK);
CHECK(insert_paged_equipment(database, "eq_tie_b", "alpha"));
CHECK(insert_paged_equipment(database, "eq_tie_a", "ALPHA"));
for (index = 0U; index < 130U; ++index) {
(void)snprintf(equipment_id, sizeof(equipment_id), "eq_page_%03zu", index);
(void)snprintf(display_name, sizeof(display_name), "Page %03zu", index);
CHECK(insert_paged_equipment(database, equipment_id, display_name));
}
CHECK(trainlog_database_list_custom_equipment_page(database, 0U, page,
TRAINLOG_CUSTOM_EQUIPMENT_PAGE_MAX, &count, &more) == TRAINLOG_STATUS_OK);
CHECK(count == TRAINLOG_CUSTOM_EQUIPMENT_PAGE_MAX);
CHECK(more);
CHECK(strcmp(page[0].equipment_id, "eq_tie_a") == 0);
CHECK(strcmp(page[1].equipment_id, "eq_tie_b") == 0);
CHECK(strcmp(page[2].equipment_id, "eq_page_000") == 0);
CHECK(strcmp(page[127].equipment_id, "eq_page_125") == 0);
CHECK(trainlog_database_list_custom_equipment_page(database, 128U, page,
TRAINLOG_CUSTOM_EQUIPMENT_PAGE_MAX, &count, &more) == TRAINLOG_STATUS_OK);
CHECK(count == 4U);
CHECK(!more);
CHECK(strcmp(page[0].equipment_id, "eq_page_126") == 0);
CHECK(strcmp(page[3].equipment_id, "eq_page_129") == 0);
CHECK(trainlog_database_list_custom_equipment_page(database, 131U, page, 1U,
&count, &more) == TRAINLOG_STATUS_OK);
CHECK(count == 1U);
CHECK(!more);
CHECK(strcmp(page[0].equipment_id, "eq_page_129") == 0);
CHECK(trainlog_database_list_custom_equipment_page(database, 132U, page, 1U,
&count, &more) == TRAINLOG_STATUS_OK);
CHECK(count == 0U);
CHECK(!more);
count = 7U;
more = true;
CHECK(trainlog_database_list_custom_equipment_page(database, 0U, page, 0U,
&count, &more) == TRAINLOG_STATUS_INVALID_ARGUMENT);
CHECK(count == 7U);
CHECK(more);
CHECK(trainlog_database_list_custom_equipment_page(database, 0U, NULL, 1U,
&count, &more) == TRAINLOG_STATUS_INVALID_ARGUMENT);
CHECK(trainlog_database_list_custom_equipment_page(database, 0U, page,
TRAINLOG_CUSTOM_EQUIPMENT_PAGE_MAX + 1U, &count, &more) == TRAINLOG_STATUS_INVALID_ARGUMENT);
if ((uintmax_t)SIZE_MAX > (uintmax_t)INT64_MAX) {
count = 7U;
more = true;
CHECK(trainlog_database_list_custom_equipment_page(database, SIZE_MAX, page, 1U,
&count, &more) == TRAINLOG_STATUS_INVALID_ARGUMENT);
CHECK(count == 0U);
CHECK(!more);
}
trainlog_database_close(database);
return true;
}
int main(void)
{
if (!test_custom_equipment_round_trip()) return 1;
if (!test_custom_equipment_page_corruption()) return 1;
if (!test_custom_equipment_page_reader()) return 1;
(void)printf("PASS custom_equipment\n");
return 0;
}

View file

@ -338,6 +338,67 @@ static bool test_transaction_rollback(void)
return true;
}
static bool test_exercise_merge_aliases_and_conflicts(void)
{
TrainlogDatabase *database = NULL;
TrainlogExerciseBodyZone zones[4];
const char *source_secondary[] = {"shoulders"};
const char *target_secondary[] = {"chest"};
char canonical[TRAINLOG_ID_MAX + 1U];
TrainlogExerciseMergePreview preview;
size_t count = 0U;
CHECK(trainlog_database_open(":memory:", &database) == TRAINLOG_STATUS_OK);
CHECK(trainlog_database_insert_exercise(database, "ex_source", "Curl A",
"curl a", TRAINLOG_TRACKING_REPS) == TRAINLOG_STATUS_OK);
CHECK(trainlog_database_insert_exercise(database, "ex_target", "Curl B",
"curl b", TRAINLOG_TRACKING_REPS) == TRAINLOG_STATUS_OK);
CHECK(trainlog_database_insert_exercise(database, "ex_final", "Curl C",
"curl c", TRAINLOG_TRACKING_REPS) == TRAINLOG_STATUS_OK);
CHECK(trainlog_database_insert_exercise(database, "ex_duration", "Hold",
"hold", TRAINLOG_TRACKING_DURATION) == TRAINLOG_STATUS_OK);
CHECK(trainlog_database_replace_exercise_body_zones(database, "ex_source",
"arms", source_secondary, 1U) == TRAINLOG_STATUS_OK);
CHECK(trainlog_database_replace_exercise_body_zones(database, "ex_target",
"arms", target_secondary, 1U) == TRAINLOG_STATUS_OK);
CHECK(trainlog_database_preview_exercise_merge(database, "ex_source",
&preview) == TRAINLOG_STATUS_OK);
CHECK(preview.occurrences == 0U && preview.performed_sets == 0U &&
preview.continuous_activities == 0U && preview.max_results == 0U &&
preview.associated_equipment == 0U && preview.body_zones == 2U);
CHECK(trainlog_database_merge_exercises(database, "ex_source", "ex_target") ==
TRAINLOG_STATUS_OK);
CHECK(trainlog_database_resolve_exercise_id(database, "ex_source", canonical,
sizeof(canonical)) == TRAINLOG_STATUS_OK);
CHECK(strcmp(canonical, "ex_target") == 0);
CHECK(trainlog_database_list_exercise_body_zones(database, "ex_target", zones,
4U, &count) == TRAINLOG_STATUS_OK);
CHECK(count == 3U);
CHECK(strcmp(zones[0].zone_id, "arms") == 0 &&
zones[0].role == TRAINLOG_BODY_ZONE_PRIMARY);
/* A second merge must collapse every old source directly to the newest
* canonical ID; resolution never depends on an unbounded alias walk. */
CHECK(trainlog_database_merge_exercises(database, "ex_target", "ex_final") ==
TRAINLOG_STATUS_OK);
CHECK(trainlog_database_resolve_exercise_id(database, "ex_source", canonical,
sizeof(canonical)) == TRAINLOG_STATUS_OK);
CHECK(strcmp(canonical, "ex_final") == 0);
CHECK(trainlog_database_resolve_exercise_id(database, "ex_target", canonical,
sizeof(canonical)) == TRAINLOG_STATUS_OK);
CHECK(strcmp(canonical, "ex_final") == 0);
CHECK(trainlog_database_merge_exercises(database, "ex_final", "ex_duration") ==
TRAINLOG_STATUS_CONFLICT);
CHECK(trainlog_database_resolve_exercise_id(database, "ex_final", canonical,
sizeof(canonical)) == TRAINLOG_STATUS_OK);
CHECK(strcmp(canonical, "ex_final") == 0);
CHECK(trainlog_database_exercise_count(database, &count) == TRAINLOG_STATUS_OK);
CHECK(count == 2U);
trainlog_database_close(database);
return true;
}
struct TestCase {
const char *name;
bool (*function)(void);
@ -352,6 +413,7 @@ int main(void)
{"session_insert", test_session_insert},
{"body_weight_history", test_body_weight_history},
{"transaction_rollback", test_transaction_rollback},
{"exercise_merge_aliases_and_conflicts", test_exercise_merge_aliases_and_conflicts},
};
size_t index;

View file

@ -362,6 +362,31 @@ static bool test_measured_max_semantics(void)
working < 85.01
);
{
TrainlogLatestExplicitMax latest = {0};
latest.found = true;
latest.load_mode = TRAINLOG_LOAD_EXTERNAL;
latest.max_weight_kg = 105.0;
(void)snprintf(latest.equipment_id, sizeof(latest.equipment_id),
"%s", "barbell");
CHECK(trainlog_measured_max_target_load(&latest, "barbell",
TRAINLOG_LOAD_EXTERNAL, 73,
&working) == TRAINLOG_STATUS_OK);
CHECK(working > 76.649 && working < 76.651);
CHECK(trainlog_measured_max_target_load(&latest, "other",
TRAINLOG_LOAD_EXTERNAL, 73,
&working) == TRAINLOG_STATUS_INVALID_ARGUMENT);
CHECK(trainlog_measured_max_target_load(&latest, "barbell",
TRAINLOG_LOAD_ASSISTANCE, 73,
&working) == TRAINLOG_STATUS_INVALID_ARGUMENT);
CHECK(trainlog_measured_max_target_load(&latest, "barbell",
TRAINLOG_LOAD_EXTERNAL, 0,
&working) == TRAINLOG_STATUS_INVALID_ARGUMENT);
CHECK(trainlog_measured_max_target_load(&latest, "barbell",
TRAINLOG_LOAD_EXTERNAL, 101,
&working) == TRAINLOG_STATUS_INVALID_ARGUMENT);
}
CHECK(
insert_reps_session(
database,

View file

@ -134,8 +134,8 @@ static bool test_v7_migration_sqlite_failure_has_diagnostic(void)
CHECK(trainlog_database_open_with_diagnostic(path, &database, diagnostic,
sizeof(diagnostic)) == TRAINLOG_STATUS_DATABASE_ERROR);
CHECK(database == NULL);
CHECK(strncmp(diagnostic, "migrate database to schema v11: SQLite rc=",
strlen("migrate database to schema v11: SQLite rc=")) == 0);
CHECK(strncmp(diagnostic, "migrate database to schema v12: SQLite rc=",
strlen("migrate database to schema v12: SQLite rc=")) == 0);
CHECK(strstr(diagnostic, "extended_rc=") != NULL);
CHECK(strstr(diagnostic, "custom_equipment") != NULL);
CHECK(strstr(diagnostic, "already exists") != NULL);
@ -145,7 +145,7 @@ static bool test_v7_migration_sqlite_failure_has_diagnostic(void)
static bool test_newer_schema_has_application_diagnostic(void)
{
char path[] = "/tmp/trainlog-schema-v12-XXXXXX";
char path[] = "/tmp/trainlog-schema-v13-XXXXXX";
char diagnostic[256];
sqlite3 *raw = NULL;
TrainlogDatabase *database = NULL;
@ -154,12 +154,12 @@ static bool test_newer_schema_has_application_diagnostic(void)
CHECK(fd >= 0);
CHECK(close(fd) == 0);
CHECK(sqlite3_open(path, &raw) == SQLITE_OK);
CHECK(sqlite3_exec(raw, "PRAGMA user_version=12;", NULL, NULL, NULL) == SQLITE_OK);
CHECK(sqlite3_exec(raw, "PRAGMA user_version=13;", NULL, NULL, NULL) == SQLITE_OK);
CHECK(sqlite3_close(raw) == SQLITE_OK);
CHECK(trainlog_database_open_with_diagnostic(path, &database, diagnostic,
sizeof(diagnostic)) == TRAINLOG_STATUS_SCHEMA_UNSUPPORTED);
CHECK(database == NULL);
CHECK(strstr(diagnostic, "schema version 12 is newer") != NULL);
CHECK(strstr(diagnostic, "schema version 13 is newer") != NULL);
CHECK(strstr(diagnostic, "SQLite") == NULL);
CHECK(unlink(path) == 0);
return true;

View file

@ -151,7 +151,7 @@ static bool test_v9_to_v10_failure_rolls_back(void)
CHECK(trainlog_database_open_with_diagnostic(path, &database, diagnostic,
sizeof(diagnostic)) == TRAINLOG_STATUS_DATABASE_ERROR);
CHECK(database == NULL);
CHECK(strstr(diagnostic, "migrate database to schema v11") != NULL);
CHECK(strstr(diagnostic, "migrate database to schema v12") != NULL);
CHECK(strstr(diagnostic, "performed_sets_v9") != NULL);
CHECK(sqlite3_open(path, &raw) == SQLITE_OK);

View file

@ -0,0 +1,614 @@
#include <stdbool.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/stat.h>
#include <sqlite3.h>
#include "trainlog/database.h"
#include "trainlog/mtp.h"
#include "trainlog/sync.h"
#include "trainlog/usb.h"
#define CHECK(value) do { \
if (!(value)) { \
(void)fprintf(stderr, "FAIL line %d: %s\n", __LINE__, #value); \
return false; \
} \
} while (0)
static const char *const SOURCE_ID =
"ex_43c7375f-934c-4650-930c-45807d2f2929";
static const char *const CANONICAL_ID =
"ex_2d488c08-194c-4051-a3c9-34471646c1d3";
static const char *const EXERCISE_NAME = "Sync wiring exercise";
static char remote_root[4096];
static const char *remote_mobile_name = "trainlog-mobile-export-v2.json";
static unsigned int remote_mobile_version = 2U;
static bool copy_file(const char *source, const char *target)
{
FILE *input = fopen(source, "rb");
FILE *output;
char buffer[4096];
size_t count;
bool copied = true;
if (input == NULL) {
return false;
}
output = fopen(target, "wb");
if (output == NULL) {
(void)fclose(input);
return false;
}
while ((count = fread(buffer, 1U, sizeof(buffer), input)) != 0U) {
if (fwrite(buffer, 1U, count, output) != count) {
copied = false;
break;
}
}
if (ferror(input) != 0) {
copied = false;
}
if (fclose(input) != 0) {
copied = false;
}
if (fclose(output) != 0) {
copied = false;
}
return copied;
}
TrainlogStatus trainlog_usb_list_mtp_devices(
TrainlogUsbDevice *output,
size_t capacity,
size_t *output_count
)
{
if (output_count == NULL || output == NULL || capacity < 1U) {
return TRAINLOG_STATUS_INVALID_ARGUMENT;
}
(void)memset(output, 0, sizeof(*output));
output->bus_number = 1U;
output->device_number = 2U;
output->mtp = true;
*output_count = 1U;
return TRAINLOG_STATUS_OK;
}
TrainlogStatus trainlog_mtp_list_storages(
unsigned int bus_number,
unsigned int device_number,
TrainlogMtpStorage *output,
size_t capacity,
size_t *output_count
)
{
(void)bus_number;
(void)device_number;
if (output_count == NULL || output == NULL || capacity < 1U) {
return TRAINLOG_STATUS_INVALID_ARGUMENT;
}
(void)memset(output, 0, sizeof(*output));
output->storage_id = 3U;
*output_count = 1U;
return TRAINLOG_STATUS_OK;
}
static void set_entry(
TrainlogMtpEntry *entry,
uint32_t item_id,
uint32_t parent_id,
const char *name,
bool folder
)
{
(void)memset(entry, 0, sizeof(*entry));
entry->item_id = item_id;
entry->parent_id = parent_id;
entry->storage_id = 3U;
entry->folder = folder;
entry->modification_unix_seconds = 1U;
(void)snprintf(entry->name, sizeof(entry->name), "%s", name);
}
TrainlogStatus trainlog_mtp_list_folder(
unsigned int bus_number,
unsigned int device_number,
uint32_t storage_id,
uint32_t parent_folder_id,
TrainlogMtpEntry *output,
size_t capacity,
size_t *output_count
)
{
(void)bus_number;
(void)device_number;
(void)storage_id;
if (output_count == NULL || output == NULL) {
return TRAINLOG_STATUS_INVALID_ARGUMENT;
}
if (parent_folder_id == UINT32_MAX) {
if (capacity < 1U) {
return TRAINLOG_STATUS_INVALID_ARGUMENT;
}
set_entry(&output[0], 1U, UINT32_MAX, "Download", true);
*output_count = 1U;
return TRAINLOG_STATUS_OK;
}
if (parent_folder_id == 1U) {
if (capacity < 1U) {
return TRAINLOG_STATUS_INVALID_ARGUMENT;
}
set_entry(&output[0], 2U, 1U, "Trainlog", true);
*output_count = 1U;
return TRAINLOG_STATUS_OK;
}
if (parent_folder_id == 2U) {
if (capacity < 3U) {
return TRAINLOG_STATUS_INVALID_ARGUMENT;
}
set_entry(&output[0], 10U, 2U, remote_mobile_name, false);
set_entry(&output[1], 11U, 2U,
"trainlog-exercise-body-zones-v1.json", false);
set_entry(&output[2], 12U, 2U,
"trainlog-equipment-associations-v2.json", false);
*output_count = 3U;
return TRAINLOG_STATUS_OK;
}
*output_count = 0U;
return TRAINLOG_STATUS_OK;
}
TrainlogStatus trainlog_mtp_receive_file(
unsigned int bus_number,
unsigned int device_number,
uint32_t item_id,
const char *local_path
)
{
char source[4096];
const char *name;
int written;
(void)bus_number;
(void)device_number;
name = item_id == 10U ? remote_mobile_name :
item_id == 11U ? "trainlog-exercise-body-zones-v1.json" :
item_id == 12U ? "trainlog-equipment-associations-v2.json" : NULL;
if (name == NULL || local_path == NULL) {
return TRAINLOG_STATUS_NOT_FOUND;
}
written = snprintf(source, sizeof(source), "%s/%s", remote_root, name);
if (written < 0 || (size_t)written >= sizeof(source) ||
!copy_file(source, local_path)) {
return TRAINLOG_STATUS_SYSTEM_ERROR;
}
return TRAINLOG_STATUS_OK;
}
TrainlogStatus trainlog_mtp_send_text_file(
unsigned int bus_number,
unsigned int device_number,
uint32_t storage_id,
uint32_t parent_folder_id,
const char *local_path,
const char *remote_filename,
uint32_t *output_item_id
)
{
(void)bus_number;
(void)device_number;
(void)storage_id;
(void)parent_folder_id;
{
char target[4096];
int written = snprintf(target, sizeof(target), "%s/%s", remote_root,
remote_filename);
if (local_path == NULL || remote_filename == NULL || written < 0 ||
(size_t)written >= sizeof(target) || !copy_file(local_path, target)) {
return TRAINLOG_STATUS_SYSTEM_ERROR;
}
}
if (output_item_id != NULL) {
*output_item_id = 99U;
}
return TRAINLOG_STATUS_OK;
}
TrainlogStatus trainlog_mtp_delete_object(
unsigned int bus_number,
unsigned int device_number,
uint32_t item_id
)
{
(void)bus_number;
(void)device_number;
(void)item_id;
return TRAINLOG_STATUS_OK;
}
static bool write_artifacts(
const char *mobile_id,
const char *zone_id,
bool historical_alias_data
)
{
char path[4096];
FILE *file;
int written = snprintf(path, sizeof(path),
"%s/%s", remote_root, remote_mobile_name);
CHECK(written >= 0 && (size_t)written < sizeof(path));
file = fopen(path, "wb");
CHECK(file != NULL);
if (historical_alias_data) {
CHECK(fprintf(file,
"{\"format\":\"trainlog-mobile-export\",\"version\":%u,"
"\"generated_at\":\"2032-01-01T00:00:00+00:00\","
"\"exercises\":["
"{\"exercise_id\":\"%s\",\"name\":\"Retired sync wiring exercise\","
"\"recording_mode\":\"sets\",\"tracking_mode\":\"reps\",\"data_fields\":0},"
"{\"exercise_id\":\"%s\",\"name\":\"%s\","
"\"recording_mode\":\"sets\",\"tracking_mode\":\"reps\",\"data_fields\":0}],"
"\"sessions\":[{\"session_id\":\"se_alias_sync_fixture\","
"\"started_at\":\"2032-01-01T01:00:00+00:00\",\"session_type\":\"training\","
"\"exercises\":["
"{\"entry_id\":\"sxe_alias_historical_a\",\"position\":0,"
"\"exercise_id\":\"%s\",\"name\":\"Retired sync wiring exercise\","
"\"recording_mode\":\"sets\",\"tracking_mode\":\"reps\",\"data_fields\":0,"
"\"load_mode\":\"none\",\"rest_seconds\":0,\"target\":null,"
"\"equipment_id\":null,\"sets\":[{\"reps\":8}]},"
"{\"entry_id\":\"sxe_alias_historical_b\",\"position\":1,"
"\"exercise_id\":\"%s\",\"name\":\"%s\","
"\"recording_mode\":\"sets\",\"tracking_mode\":\"reps\",\"data_fields\":0,"
"\"load_mode\":\"none\",\"rest_seconds\":0,\"target\":null,"
"\"equipment_id\":null,\"sets\":[{\"reps\":9}]}]}],"
"\"body_observations\":[]}",
remote_mobile_version, SOURCE_ID, CANONICAL_ID, EXERCISE_NAME,
SOURCE_ID, CANONICAL_ID, EXERCISE_NAME) > 0);
} else {
CHECK(fprintf(file,
"{\"format\":\"trainlog-mobile-export\",\"version\":%u,"
"\"generated_at\":\"2032-01-01T00:00:00+00:00\","
"\"exercises\":[{\"exercise_id\":\"%s\",\"name\":\"%s\","
"\"recording_mode\":\"sets\",\"tracking_mode\":\"reps\","
"\"data_fields\":0}],\"sessions\":[],\"body_observations\":[]}",
remote_mobile_version, mobile_id, EXERCISE_NAME) > 0);
}
CHECK(fclose(file) == 0);
written = snprintf(path, sizeof(path),
"%s/trainlog-exercise-body-zones-v1.json", remote_root);
CHECK(written >= 0 && (size_t)written < sizeof(path));
file = fopen(path, "wb");
CHECK(file != NULL);
CHECK(fprintf(file,
"{\"format\":\"trainlog-exercise-body-zones\",\"version\":1,"
"\"generated_at\":\"2032-01-01T00:00:00+00:00\","
"\"exercises\":[{\"exercise_id\":\"%s\","
"\"primary_zone_id\":\"back\",\"secondary_zone_ids\":[\"arms\"]},"
"{\"exercise_id\":\"%s\",\"primary_zone_id\":\"back\","
"\"secondary_zone_ids\":[\"arms\"]}]}",
zone_id, CANONICAL_ID) > 0);
CHECK(fclose(file) == 0);
written = snprintf(path, sizeof(path),
"%s/trainlog-equipment-associations-v2.json", remote_root);
CHECK(written >= 0 && (size_t)written < sizeof(path));
file = fopen(path, "wb");
CHECK(file != NULL);
CHECK(fprintf(file,
"{\"format\":\"trainlog-equipment-associations\",\"version\":2,"
"\"generated_at\":\"2032-01-01T00:00:00+00:00\",\"associations\":%s}",
historical_alias_data
? "[{\"session_id\":\"se_alias_sync_fixture\",\"entry_id\":\"sxe_alias_historical_a\",\"exercise_id\":\"ex_43c7375f-934c-4650-930c-45807d2f2929\",\"state\":\"cleared\"},{\"session_id\":\"se_alias_sync_fixture\",\"entry_id\":\"sxe_alias_historical_b\",\"exercise_id\":\"ex_2d488c08-194c-4051-a3c9-34471646c1d3\",\"state\":\"cleared\"}]"
: "[]") > 0);
CHECK(fclose(file) == 0);
return true;
}
static bool prepare_database(
const char *case_root,
bool persistent_alias,
bool initial_zone,
char *database_path,
size_t database_path_size
)
{
TrainlogDatabase *database = NULL;
const char *no_secondary[1] = {NULL};
char data_directory[4096];
int written;
CHECK(mkdir(case_root, 0700) == 0);
CHECK(setenv("XDG_DATA_HOME", case_root, 1) == 0);
written = snprintf(data_directory, sizeof(data_directory),
"%s/trainlog", case_root);
CHECK(written >= 0 && (size_t)written < sizeof(data_directory));
CHECK(mkdir(data_directory, 0700) == 0);
written = snprintf(database_path, database_path_size,
"%s/trainlog.db", data_directory);
CHECK(written >= 0 && (size_t)written < database_path_size);
CHECK(trainlog_database_open(database_path, &database) == TRAINLOG_STATUS_OK);
CHECK(trainlog_database_insert_exercise_profiled(
database, CANONICAL_ID, EXERCISE_NAME, "sync wiring exercise",
TRAINLOG_TRACKING_REPS, TRAINLOG_RECORDING_SETS,
UINT32_C(0)) == TRAINLOG_STATUS_OK);
if (initial_zone) {
CHECK(trainlog_database_replace_exercise_body_zones(
database, CANONICAL_ID, "chest", no_secondary, 0U) ==
TRAINLOG_STATUS_OK);
}
if (persistent_alias) {
CHECK(trainlog_database_insert_exercise_profiled(
database, SOURCE_ID, "Retired sync wiring exercise",
"retired sync wiring exercise", TRAINLOG_TRACKING_REPS,
TRAINLOG_RECORDING_SETS, UINT32_C(0)) ==
TRAINLOG_STATUS_OK);
CHECK(trainlog_database_merge_exercises(
database, SOURCE_ID, CANONICAL_ID) == TRAINLOG_STATUS_OK);
}
trainlog_database_close(database);
return true;
}
static bool database_state(
const char *database_path,
bool source_should_resolve,
const char *expected_primary,
const char *expected_secondary,
size_t expected_zone_count
)
{
TrainlogDatabase *database = NULL;
TrainlogExerciseBodyZone zones[4];
char resolved[TRAINLOG_ID_MAX + 1U];
size_t count = 0U;
size_t index;
bool found_primary = false;
bool found_secondary = expected_secondary == NULL;
sqlite3 *raw = NULL;
sqlite3_stmt *statement = NULL;
CHECK(trainlog_database_open(database_path, &database) == TRAINLOG_STATUS_OK);
CHECK(trainlog_database_resolve_exercise_id(
database, SOURCE_ID, resolved, sizeof(resolved)) ==
(source_should_resolve ? TRAINLOG_STATUS_OK : TRAINLOG_STATUS_NOT_FOUND));
if (source_should_resolve) {
CHECK(strcmp(resolved, CANONICAL_ID) == 0);
}
CHECK(trainlog_database_list_exercise_body_zones(
database, CANONICAL_ID, zones, 4U, &count) == TRAINLOG_STATUS_OK);
CHECK(count == expected_zone_count);
for (index = 0U; index < count; ++index) {
if (zones[index].role == TRAINLOG_BODY_ZONE_PRIMARY &&
strcmp(zones[index].zone_id, expected_primary) == 0) {
found_primary = true;
}
if (expected_secondary != NULL &&
zones[index].role == TRAINLOG_BODY_ZONE_SECONDARY &&
strcmp(zones[index].zone_id, expected_secondary) == 0) {
found_secondary = true;
}
}
CHECK(found_primary && found_secondary);
trainlog_database_close(database);
/* INVARIANT: alias coalescing updates one canonical row; it never
* resurrects the retired creator or duplicates mappings/aliases. */
CHECK(sqlite3_open(database_path, &raw) == SQLITE_OK);
CHECK(sqlite3_prepare_v2(raw, "SELECT COUNT(*) FROM exercises", -1,
&statement, NULL) == SQLITE_OK);
CHECK(sqlite3_step(statement) == SQLITE_ROW);
CHECK(sqlite3_column_int(statement, 0) == 1);
CHECK(sqlite3_finalize(statement) == SQLITE_OK);
statement = NULL;
if (source_should_resolve) {
CHECK(sqlite3_prepare_v2(raw,
"SELECT COUNT(*) FROM exercise_aliases WHERE source_exercise_id=?1 "
"AND canonical_exercise_id=?2", -1, &statement, NULL) == SQLITE_OK);
CHECK(sqlite3_bind_text(statement, 1, SOURCE_ID, -1, SQLITE_STATIC) == SQLITE_OK);
CHECK(sqlite3_bind_text(statement, 2, CANONICAL_ID, -1, SQLITE_STATIC) == SQLITE_OK);
CHECK(sqlite3_step(statement) == SQLITE_ROW);
CHECK(sqlite3_column_int(statement, 0) == 1);
CHECK(sqlite3_finalize(statement) == SQLITE_OK);
}
CHECK(sqlite3_close(raw) == SQLITE_OK);
return true;
}
static bool successful_journal(const char *case_root, const char *sync_id)
{
char path[4096];
char content[8192];
FILE *file;
size_t count;
int written = snprintf(path, sizeof(path), "%s/trainlog/sync_runs/%s.json",
case_root, sync_id);
CHECK(written >= 0 && (size_t)written < sizeof(path));
file = fopen(path, "rb");
CHECK(file != NULL);
count = fread(content, 1U, sizeof(content) - 1U, file);
CHECK(ferror(file) == 0);
CHECK(fclose(file) == 0);
content[count] = '\0';
CHECK(strstr(content, "\"status\":\"success\"") != NULL);
return true;
}
static bool file_contains(const char *name, const char *needle, bool expected)
{
char path[4096];
char content[65536];
FILE *file;
size_t count;
int written = snprintf(path, sizeof(path), "%s/%s", remote_root, name);
CHECK(written >= 0 && (size_t)written < sizeof(path));
file = fopen(path, "rb");
CHECK(file != NULL);
count = fread(content, 1U, sizeof(content) - 1U, file);
CHECK(ferror(file) == 0);
CHECK(fclose(file) == 0);
content[count] = '\0';
CHECK((strstr(content, needle) != NULL) == expected);
return true;
}
static bool canonical_alias_exports(void)
{
/* CONTRACT: retired identities are published only by the alias companion;
* every catalogue, session, zone, and equipment reference uses live B. */
static const char *const canonical_outputs[] = {
"trainlog-pc-catalog-v1.json",
"trainlog-pc-mobile-export-v3.json",
"trainlog-exercise-body-zones-v1.json",
"trainlog-equipment-associations-v2.json",
};
size_t index;
for (index = 0U;
index < sizeof(canonical_outputs) / sizeof(canonical_outputs[0]);
++index) {
CHECK(file_contains(canonical_outputs[index], SOURCE_ID, false));
CHECK(file_contains(canonical_outputs[index], CANONICAL_ID, true));
}
CHECK(file_contains("trainlog-exercise-aliases-v1.json", SOURCE_ID, true));
CHECK(file_contains("trainlog-exercise-aliases-v1.json", CANONICAL_ID, true));
return true;
}
static bool run_success_case(
const char *case_root,
bool persistent_alias
)
{
TrainlogSyncReport report;
char database_path[4096];
if (persistent_alias) {
remote_mobile_name = "trainlog-mobile-export-v3.json";
remote_mobile_version = 3U;
}
CHECK(prepare_database(case_root, persistent_alias, false,
database_path, sizeof(database_path)));
CHECK(write_artifacts(SOURCE_ID, SOURCE_ID, persistent_alias));
CHECK(trainlog_sync_run(TRAINLOG_SYNC_TRIGGER_TUI, false,
persistent_alias ? TRAINLOG_SYNC_BIDIRECTIONAL :
TRAINLOG_SYNC_ANDROID_TO_PC, &report) == TRAINLOG_STATUS_OK);
CHECK(report.success);
CHECK(database_state(database_path, persistent_alias, "back", "arms", 2U));
CHECK(successful_journal(case_root, report.sync_id));
if (persistent_alias) {
sqlite3 *raw = NULL;
sqlite3_stmt *statement = NULL;
CHECK(sqlite3_open(database_path, &raw) == SQLITE_OK);
CHECK(sqlite3_prepare_v2(raw,
"SELECT COUNT(*) FROM session_exercises se JOIN exercises e "
"ON e.id=se.exercise_row_id WHERE se.entry_id IN "
"('sxe_alias_historical_a','sxe_alias_historical_b') "
"AND e.exercise_id=?1", -1, &statement, NULL) == SQLITE_OK);
CHECK(sqlite3_bind_text(statement, 1, CANONICAL_ID, -1,
SQLITE_STATIC) == SQLITE_OK);
CHECK(sqlite3_step(statement) == SQLITE_ROW);
CHECK(sqlite3_column_int(statement, 0) == 2);
CHECK(sqlite3_finalize(statement) == SQLITE_OK);
CHECK(sqlite3_close(raw) == SQLITE_OK);
CHECK(canonical_alias_exports());
}
/* Replay exercises the same production argv path and sync baseline. */
CHECK(trainlog_sync_run(TRAINLOG_SYNC_TRIGGER_TUI, false,
persistent_alias ? TRAINLOG_SYNC_BIDIRECTIONAL :
TRAINLOG_SYNC_ANDROID_TO_PC, &report) == TRAINLOG_STATUS_OK);
CHECK(report.success);
CHECK(database_state(database_path, persistent_alias, "back", "arms", 2U));
CHECK(successful_journal(case_root, report.sync_id));
if (persistent_alias) {
CHECK(canonical_alias_exports());
remote_mobile_name = "trainlog-mobile-export-v2.json";
remote_mobile_version = 2U;
}
return true;
}
static bool run_unknown_without_proof_case(const char *case_root)
{
TrainlogSyncReport report;
char database_path[4096];
CHECK(prepare_database(case_root, false, true,
database_path, sizeof(database_path)));
CHECK(write_artifacts(CANONICAL_ID, SOURCE_ID, false));
CHECK(trainlog_sync_run(TRAINLOG_SYNC_TRIGGER_TUI, false,
TRAINLOG_SYNC_ANDROID_TO_PC, &report) ==
TRAINLOG_STATUS_DATABASE_ERROR);
CHECK(!report.success);
CHECK(strstr(report.error, "exercice inconnu") != NULL);
CHECK(database_state(database_path, false, "chest", NULL, 1U));
return true;
}
static bool run_v3_unknown_with_stale_v2_proof_case(const char *case_root)
{
TrainlogSyncReport report;
char database_path[4096];
FILE *stale;
/* This file deliberately survives from an earlier V2 run. A selected V3
* cycle must not discover it as reconciliation evidence for its companion. */
stale = fopen("/tmp/trainlog-mobile-export-v2.json", "wb");
CHECK(stale != NULL);
CHECK(fprintf(stale,
"{\"format\":\"trainlog-mobile-export\",\"version\":2,"
"\"generated_at\":\"2032-01-01T00:00:00+00:00\","
"\"exercises\":[{\"exercise_id\":\"%s\",\"name\":\"%s\","
"\"recording_mode\":\"sets\",\"tracking_mode\":\"reps\","
"\"data_fields\":0}],\"sessions\":[],\"body_observations\":[]}",
SOURCE_ID, EXERCISE_NAME) > 0);
CHECK(fclose(stale) == 0);
remote_mobile_name = "trainlog-mobile-export-v3.json";
remote_mobile_version = 3U;
CHECK(prepare_database(case_root, false, true,
database_path, sizeof(database_path)));
CHECK(write_artifacts(CANONICAL_ID, SOURCE_ID, false));
CHECK(trainlog_sync_run(TRAINLOG_SYNC_TRIGGER_TUI, false,
TRAINLOG_SYNC_ANDROID_TO_PC, &report) ==
TRAINLOG_STATUS_DATABASE_ERROR);
CHECK(!report.success);
CHECK(strstr(report.error, "exercice inconnu") != NULL);
CHECK(database_state(database_path, false, "chest", NULL, 1U));
CHECK(remove("/tmp/trainlog-mobile-export-v2.json") == 0);
remote_mobile_name = "trainlog-mobile-export-v2.json";
remote_mobile_version = 2U;
return true;
}
static bool run_all(void)
{
char temporary[] = "/tmp/trainlog-sync-body-zone-wiring-XXXXXX";
char case_a[4096];
char case_b[4096];
char case_c[4096];
char case_d[4096];
char *root = mkdtemp(temporary);
CHECK(root != NULL);
CHECK(snprintf(remote_root, sizeof(remote_root), "%s/remote", root) > 0);
CHECK(mkdir(remote_root, 0700) == 0);
CHECK(snprintf(case_a, sizeof(case_a), "%s/case-a", root) > 0);
CHECK(snprintf(case_b, sizeof(case_b), "%s/case-b", root) > 0);
CHECK(snprintf(case_c, sizeof(case_c), "%s/case-c", root) > 0);
CHECK(snprintf(case_d, sizeof(case_d), "%s/case-d", root) > 0);
CHECK(run_success_case(case_a, false));
CHECK(run_success_case(case_b, true));
CHECK(run_unknown_without_proof_case(case_c));
CHECK(run_v3_unknown_with_stale_v2_proof_case(case_d));
(void)puts("PASS production sync body-zone V2 proof wiring");
return true;
}
int main(void)
{
return run_all() ? EXIT_SUCCESS : EXIT_FAILURE;
}

View file

@ -30,23 +30,6 @@ static TrainlogStatus record_run(
return TRAINLOG_STATUS_OK;
}
static int check_direction_key(
int key,
TrainlogSyncDirection expected
)
{
TrainlogSyncScreenState state;
TrainlogSyncScreenAction action;
trainlog_sync_screen_state_init(&state);
action = trainlog_sync_screen_dispatch(&state, key);
CHECK(action.effect == TRAINLOG_SYNC_SCREEN_CONFIRM);
CHECK(action.direction == expected);
CHECK(state.confirming);
CHECK(state.direction == expected);
return 0;
}
int main(void)
{
TrainlogSyncScreenState state;
@ -54,19 +37,23 @@ int main(void)
TrainlogSyncReport report = {0};
RunRecorder recorder = {0};
CHECK(check_direction_key('a', TRAINLOG_SYNC_ANDROID_TO_PC) == 0);
CHECK(check_direction_key('p', TRAINLOG_SYNC_PC_TO_ANDROID) == 0);
CHECK(check_direction_key('b', TRAINLOG_SYNC_BIDIRECTIONAL) == 0);
trainlog_sync_screen_state_init(&state);
action = trainlog_sync_screen_dispatch(&state, 'a');
CHECK(trainlog_sync_screen_dispatch(&state, 'a').effect ==
TRAINLOG_SYNC_SCREEN_NONE);
CHECK(trainlog_sync_screen_dispatch(&state, 'p').effect ==
TRAINLOG_SYNC_SCREEN_NONE);
CHECK(trainlog_sync_screen_dispatch(&state, 'b').effect ==
TRAINLOG_SYNC_SCREEN_NONE);
action = trainlog_sync_screen_dispatch(&state, 's');
CHECK(action.effect == TRAINLOG_SYNC_SCREEN_CONFIRM);
CHECK(action.direction == TRAINLOG_SYNC_BIDIRECTIONAL);
CHECK(state.confirming);
CHECK(state.direction == TRAINLOG_SYNC_BIDIRECTIONAL);
action = trainlog_sync_screen_dispatch(&state, 27);
CHECK(action.effect == TRAINLOG_SYNC_SCREEN_CANCEL);
CHECK(!state.confirming);
CHECK(recorder.calls == 0U);
action = trainlog_sync_screen_dispatch(&state, 'p');
action = trainlog_sync_screen_dispatch(&state, 's');
CHECK(action.effect == TRAINLOG_SYNC_SCREEN_CONFIRM);
action = trainlog_sync_screen_dispatch(&state, '\n');
CHECK(action.effect == TRAINLOG_SYNC_SCREEN_RUN);
@ -74,7 +61,7 @@ int main(void)
&action, record_run, &recorder, &report
) == TRAINLOG_STATUS_OK);
CHECK(recorder.calls == 1U);
CHECK(recorder.direction == TRAINLOG_SYNC_PC_TO_ANDROID);
CHECK(recorder.direction == TRAINLOG_SYNC_BIDIRECTIONAL);
action = trainlog_sync_screen_dispatch(&state, '\n');
CHECK(action.effect == TRAINLOG_SYNC_SCREEN_NONE);
CHECK(recorder.calls == 1U);
@ -82,7 +69,9 @@ int main(void)
action = trainlog_sync_screen_dispatch(&state, 'r');
CHECK(action.effect == TRAINLOG_SYNC_SCREEN_REFRESH);
CHECK(recorder.calls == 1U);
action = trainlog_sync_screen_dispatch(&state, 's');
/* Directional keys remain inert after a complete run too. */
action = trainlog_sync_screen_dispatch(&state, 'a');
CHECK(action.effect == TRAINLOG_SYNC_SCREEN_NONE);
CHECK(recorder.calls == 1U);
@ -99,14 +88,10 @@ int main(void)
"Synchroniser Android et PC dans les deux sens maintenant ?"
) == 0);
CHECK(strstr(trainlog_sync_screen_footer(120), "a Android→PC") != NULL);
CHECK(strstr(trainlog_sync_screen_footer(120), "p PC→Android") != NULL);
CHECK(strstr(trainlog_sync_screen_footer(120), "b PC↔Android") != NULL);
CHECK(strstr(trainlog_sync_screen_footer(80), "a A→PC") != NULL);
CHECK(strstr(trainlog_sync_screen_footer(80), "p PC→A") != NULL);
CHECK(strstr(trainlog_sync_screen_footer(80), "b A↔PC") != NULL);
CHECK(strstr(trainlog_sync_screen_footer(120), "s synchroniser") == NULL);
CHECK(strstr(trainlog_sync_screen_footer(80), "mode") == NULL);
CHECK(strstr(trainlog_sync_screen_footer(120), "Synchroniser maintenant") != NULL);
CHECK(strstr(trainlog_sync_screen_footer(80), "Synchroniser PC↔Android") != NULL);
CHECK(strstr(trainlog_sync_screen_footer(120), "Android→PC") == NULL);
CHECK(strstr(trainlog_sync_screen_footer(120), "PC→Android") == NULL);
puts("PASS sync screen action dispatcher");
return 0;

View file

@ -64,6 +64,18 @@ static bool test_key_translation(void)
NCKEY_TAB, TRAINLOG_INPUT_PRESS, true, &key));
CHECK(key == TRAINLOG_KEY_SHIFT_TAB);
CHECK(trainlog_terminal_translate_input(
NCKEY_F06, TRAINLOG_INPUT_PRESS, false, &key));
CHECK(key == TRAINLOG_KEY_F6);
CHECK(trainlog_terminal_translate_input(
NCKEY_F07, TRAINLOG_INPUT_REPEAT, false, &key));
CHECK(key == TRAINLOG_KEY_F7);
CHECK(trainlog_terminal_translate_input(
27U, TRAINLOG_INPUT_PRESS, false, &key));
CHECK(key == TRAINLOG_KEY_ESCAPE);
CHECK(trainlog_terminal_translate_input(
0x00e9U, TRAINLOG_INPUT_REPEAT, false, &key));
CHECK(key == 0x00e9);

View file

@ -91,6 +91,15 @@ int main(void)
max_occurrence.load_mode=TRAINLOG_LOAD_NONE;
CHECK(insert_session(database,"session_max","2026-09-09T10:00:00+02:00",TRAINLOG_SESSION_MAX_TEST,&max_occurrence,1U));
CHECK(trainlog_database_latest_explicit_max_equipment_context(database,
exercise_id, "leg_press", &context.latest_max) == TRAINLOG_STATUS_OK);
CHECK(context.latest_max.found && context.latest_max.max_weight_kg == 120.0);
CHECK(trainlog_database_latest_explicit_max_equipment_context(database,
exercise_id, "plate_loaded_leg_press", &context.latest_max) == TRAINLOG_STATUS_OK);
CHECK(!context.latest_max.found);
CHECK(trainlog_database_latest_explicit_max_equipment_context(database,
exercise_id, "", &context.latest_max) == TRAINLOG_STATUS_INVALID_ARGUMENT);
CHECK(trainlog_training_exercise_context_load(database,exercise_id,4U,1U,&context)==TRAINLOG_STATUS_OK);
CHECK(strcmp(context.exercise.name,"Leg press renamed")==0);
CHECK(context.knowledge!=NULL && context.knowledge->interpretation!=NULL);

Some files were not shown because too many files have changed in this diff Show more